Skip to content

Commit a9b60ce

Browse files
authored
Merge 26fc382 into 6b01449
2 parents 6b01449 + 26fc382 commit a9b60ce

File tree

4 files changed

+328
-4
lines changed

4 files changed

+328
-4
lines changed

projectDocs/dev/userGuideStandards.md

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Once the anchor is set it cannot be updated, while settings may move categories.
2323
2424
|| Options | Default (Enabled), Enabled, Disabled |
2525
|| Default | Enabled |
26-
|| Toggle command | ``nvda+shift+e`` |
26+
|| Toggle command | ``NVDA+shift+e`` |
2727
2828
|| Option || Behaviour ||
2929
| Enabled | behaviour of enabled |
@@ -32,3 +32,68 @@ Once the anchor is set it cannot be updated, while settings may move categories.
3232
This setting allows the feature of using functionality in a certain situation to be controlled in some way.
3333
If necessary, a description of a common use case that is supported by each option.
3434
```
35+
36+
## Generation of Key Commands
37+
Generation of the Key Commands document requires certain commands to be included in the User Guide.
38+
These commands must begin at the start of the line and take the form:
39+
40+
```t2t
41+
%kc:command: arg
42+
```
43+
44+
The title command must appear first.
45+
The other commands are used to select content containing key commands to be included into the key commands document.
46+
Appropriate headings from the User Guide will be included implicitly.
47+
48+
### Specifying the title
49+
The `kc:title` command must appear first and specifies the title of the Key Commands document.
50+
51+
For example:
52+
```t2t
53+
%kc:title: NVDA Key Commands
54+
```
55+
56+
### Selecting blocks
57+
58+
The `kc:beginInclude` command begins a block of text which should be included verbatim.
59+
The block ends at the `kc:endInclude` command.
60+
61+
For example:
62+
63+
```t2t
64+
%kc:beginInclude
65+
|| Name | Desktop command | Laptop command | Description |
66+
...
67+
%kc:endInclude
68+
```
69+
70+
### Headers for settings sections
71+
72+
The `kc:settingsSection` command indicates the beginning of a section documenting individual settings.
73+
It specifies the header row for a table summarising the settings indicated by the `kc:setting` command (see below).
74+
In order, it must consist of a name column, a column for each keyboard layout and a description column.
75+
76+
For example:
77+
```t2t
78+
%kc:settingsSection: || Name | Desktop command | Laptop command | Description |
79+
```
80+
81+
### Including a setting
82+
83+
The `kc:setting` command indicates a section for an individual setting.
84+
85+
It must be followed by:
86+
87+
* A heading containing the name of the setting;
88+
* A table row for each keyboard layout, or if the key is common to all layouts, a single line of text specifying the key after a colon;
89+
* A blank line; and
90+
* A line describing the setting.
91+
92+
For example:
93+
```t2t
94+
%kc:setting
95+
==== Braille Tethered To ====
96+
| Desktop command | ``NVDA+control+t`` |
97+
| Laptop Command | ``NVDA+control+t`` |
98+
This option allows you to choose whether the braille display will follow the system focus, or whether it follows the navigator object / review cursor.
99+
```

site_scons/site_tools/md2html.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def md2html_actionFunc(
116116

117117
extensions = set(DEFAULT_EXTENSIONS)
118118
if isKeyCommands:
119-
from keyCommandsDoc import KeyCommandsExtension
119+
from user_docs.keyCommandsDoc import KeyCommandsExtension
120120
extensions.add(KeyCommandsExtension())
121121

122122
markdown.markdownFromFile(
@@ -146,7 +146,7 @@ def exists(env: SCons.Environment.Environment) -> bool:
146146
"markdown_link_attr_modifier",
147147
"mdx_truly_sane_lists",
148148
"mdx_gh_links",
149-
"keyCommandsDoc",
149+
"user_docs.keyCommandsDoc",
150150
]:
151151
if find_spec(ext) is None:
152152
return False

site_scons/site_tools/t2t.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def txt2tags_actionFunc(
1212
env: SCons.Environment.Environment
1313
):
1414
import txt2tags
15-
from keyCommandsDoc import Command
15+
from user_docs.keyCommandsDoc import Command
1616

1717
with open(source[0].path, "r", encoding="utf-8") as mdFile:
1818
mdOriginal = mdFile.read()

user_docs/keyCommandsDoc.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
# -*- coding: UTF-8 -*-
2+
# A part of NonVisual Desktop Access (NVDA)
3+
# Copyright (C) 2010-2024 NV Access Limited, Mesar Hameed, Takuya Nishimoto
4+
# This file is covered by the GNU General Public License.
5+
# See the file COPYING for more details.
6+
7+
"""
8+
Generates the Key Commands document from the User Guide.
9+
Works as a Python Markdown Extension:
10+
https://python-markdown.github.io/extensions/
11+
12+
Refer to user guide standards for more information on syntax rules:
13+
https://github.com/nvaccess/nvda/blob/master/projectDocs/dev/userGuideStandards.md
14+
"""
15+
16+
from enum import auto, Enum, IntEnum, StrEnum
17+
import re
18+
from collections.abc import Iterator
19+
20+
from markdown import Extension, Markdown
21+
from markdown.preprocessors import Preprocessor
22+
23+
24+
LINE_END = "\r\n"
25+
26+
27+
class Section(IntEnum):
28+
"""Sections must be nested in this order."""
29+
HEADER = auto()
30+
BODY = auto()
31+
32+
33+
class Command(StrEnum):
34+
TITLE = "title"
35+
BEGIN_INCLUDE = "beginInclude"
36+
END_INCLUDE = "endInclude"
37+
SETTING = "setting"
38+
SETTINGS_SECTION = "settingsSection"
39+
40+
def t2tRegex(self) -> re.Pattern:
41+
return re.compile(rf"%kc:({self.value}.*)")
42+
43+
44+
class Regex(Enum):
45+
COMMAND = re.compile(r"^<!-- KC:(?P<cmd>[^:\s]+)(?:: (?P<arg>.*))? -->$")
46+
HEADING = re.compile(r"^(?P<id>#+)(?P<txt>.*)$")
47+
SETTING_SINGLE_KEY = re.compile(r"^[^|]+?[::]\s*(.+?)\s*$")
48+
TABLE_ROW = re.compile(r"^(\|.*\|)$")
49+
50+
51+
class KeyCommandsError(Exception):
52+
"""Raised due to an error encountered in the User Guide related to generation of the Key Commands document.
53+
"""
54+
55+
56+
class KeyCommandsExtension(Extension):
57+
# Magic number, priorities are not well documented.
58+
# It's unclear what the range of priorities are to compare to, but 25 seems to work, 1 doesn't.
59+
# See https://python-markdown.github.io/extensions/api/#registries
60+
PRIORITY = 25
61+
62+
def extendMarkdown(self, md: Markdown):
63+
md.preprocessors.register(KeyCommandsPreprocessor(md), 'key_commands', self.PRIORITY)
64+
65+
66+
class KeyCommandsPreprocessor(Preprocessor):
67+
def __init__(self, md: Markdown | None):
68+
super().__init__(md)
69+
self.initialize()
70+
71+
def initialize(self):
72+
self._ugLines: Iterator[str] = iter(())
73+
self._kcLines: list[str] = []
74+
#: The current section of the key commands file.
75+
self._kcSect: Section = Section.HEADER
76+
#: The current stack of headings.
77+
self._headings: list[re.Match] = []
78+
#: The 0 based level of the last heading in L{_headings} written to the key commands file.
79+
self._kcLastHeadingLevel: int = -1
80+
#: Whether lines which aren't commands should be written to the key commands file as is.
81+
self._kcInclude: bool = False
82+
#: The header row for settings sections.
83+
self._settingsHeaderRow: str | None = None
84+
#: The number of layouts for settings in a settings section.
85+
self._settingsNumLayouts: int = 0
86+
#: The current line number being processed, used to present location of syntax errors
87+
self._lineNum: int = 0
88+
# We want to skip the title line to replace it with the KC:TITLE command argument.
89+
self._skippedTitle = False
90+
91+
def run(self, lines: list[str]) -> list[str]:
92+
# Turn this into an iterator so we can use next() to seek through lines.
93+
self._ugLines = iter(lines)
94+
for line in self._ugLines:
95+
line = line.strip()
96+
self._lineNum += 1
97+
98+
# We want to skip the title line to replace it with the KC:TITLE command argument.
99+
if line.startswith("# ") and not self._skippedTitle:
100+
self._skippedTitle = True
101+
continue
102+
103+
m = Regex.COMMAND.value.match(line)
104+
if m:
105+
self._command(**m.groupdict())
106+
continue
107+
108+
m = Regex.HEADING.value.match(line)
109+
if m:
110+
self._heading(m)
111+
continue
112+
113+
if self._kcInclude:
114+
self._kcLines.append(line)
115+
116+
return self._kcLines.copy()
117+
118+
def _command(self, cmd: Command | None = None, arg: str | None = None):
119+
# Handle header commands.
120+
if cmd == Command.TITLE.value:
121+
if self._kcSect > Section.HEADER:
122+
raise KeyCommandsError(f"{self._lineNum}, title command is not valid here")
123+
# Write the title and two blank lines to complete the txt2tags header section.
124+
self._kcLines.append("# " + arg + LINE_END * 2)
125+
self._kcSect = Section.BODY
126+
return
127+
128+
elif self._kcSect == Section.HEADER:
129+
raise KeyCommandsError(f"{self._lineNum}, title must be the first command")
130+
131+
if cmd == Command.BEGIN_INCLUDE.value:
132+
self._writeHeadings()
133+
self._kcInclude = True
134+
elif cmd == Command.END_INCLUDE.value:
135+
self._kcInclude = False
136+
self._kcLines.append("")
137+
138+
elif cmd == Command.SETTINGS_SECTION.value:
139+
# The argument is the table header row for the settings section.
140+
# Replace t2t header syntax with markdown syntax.
141+
self._settingsHeaderRow = arg.replace("||", "|")
142+
# There are name and description columns.
143+
# Each of the remaining columns provides keystrokes for one layout.
144+
# There's one less delimiter than there are columns, hence subtracting 1 instead of 2.
145+
self._settingsNumLayouts = arg.strip("|").count("|") - 1
146+
if self._settingsNumLayouts < 1:
147+
raise KeyCommandsError(
148+
f"{self._lineNum}, settingsSection command must specify the header row for a table"
149+
" summarising the settings"
150+
)
151+
152+
elif cmd == Command.SETTING.value:
153+
self._handleSetting()
154+
155+
else:
156+
raise KeyCommandsError(f"{self._lineNum}, Invalid command {cmd}")
157+
158+
def _seekNonEmptyLine(self) -> str:
159+
"""Seeks to the next non-empty line in the user guide.
160+
"""
161+
line = next(self._ugLines).strip()
162+
self._lineNum += 1
163+
while not line:
164+
try:
165+
line = next(self._ugLines).strip()
166+
except StopIteration:
167+
return line
168+
self._lineNum += 1
169+
return line
170+
171+
def _areHeadingsPending(self) -> bool:
172+
return self._kcLastHeadingLevel < len(self._headings) - 1
173+
174+
def _writeHeadings(self):
175+
level = self._kcLastHeadingLevel + 1
176+
# Only write headings we haven't yet written.
177+
for level, heading in enumerate(self._headings[level:], level):
178+
self._kcLines.append(heading.group(0))
179+
self._kcLastHeadingLevel = level
180+
181+
def _heading(self, m: re.Match):
182+
# We work with 0 based heading levels.
183+
level = len(m.group("id")) - 1
184+
try:
185+
del self._headings[level:]
186+
except IndexError:
187+
pass
188+
self._headings.append(m)
189+
self._kcLastHeadingLevel = min(self._kcLastHeadingLevel, level - 1)
190+
191+
def _handleSetting(self):
192+
if not self._settingsHeaderRow:
193+
raise KeyCommandsError("%d, setting command cannot be used before settingsSection command" % self._lineNum)
194+
195+
if self._areHeadingsPending():
196+
# There are new headings to write.
197+
# If there was a previous settings table, it ends here, so write a blank line.
198+
self._kcLines.append("")
199+
self._writeHeadings()
200+
# New headings were written, so we need to output the header row.
201+
self._kcLines.append(self._settingsHeaderRow)
202+
numCols = self._settingsNumLayouts + 2 # name + description + layouts
203+
self._kcLines.append("|" + "---|" * numCols)
204+
205+
# The next line should be a heading which is the name of the setting.
206+
line = self._seekNonEmptyLine()
207+
m = Regex.HEADING.value.match(line)
208+
if not m:
209+
raise KeyCommandsError(f"{self._lineNum}, setting command must be followed by heading")
210+
name = m.group("txt")
211+
212+
# The next few lines should be table rows for each layout.
213+
# Alternatively, if the key is common to all layouts, there will be a single line of text specifying the key after a colon.
214+
keys: list[str] = []
215+
for _layout in range(self._settingsNumLayouts):
216+
line = self._seekNonEmptyLine()
217+
218+
m = Regex.SETTING_SINGLE_KEY.value.match(line)
219+
if m:
220+
keys.append(m.group(1))
221+
break
222+
elif not Regex.TABLE_ROW.value.match(line):
223+
raise KeyCommandsError(
224+
f"{self._lineNum}, setting command: "
225+
"There must be one table row for each keyboard layout"
226+
)
227+
228+
# This is a table row.
229+
# The key will be the second column.
230+
try:
231+
key = line.strip("|").split("|")[1].strip()
232+
except IndexError:
233+
raise KeyCommandsError(f"{self._lineNum}, setting command: Key entry not found in table row.")
234+
else:
235+
keys.append(key)
236+
237+
if 1 == len(keys) < self._settingsNumLayouts:
238+
# The key has only been specified once, so it is the same in all layouts.
239+
key = keys[0]
240+
keys[1:] = (key for _layout in range(self._settingsNumLayouts - 1))
241+
242+
# There should now be a blank line.
243+
line = next(self._ugLines).strip()
244+
self._lineNum += 1
245+
if line:
246+
raise KeyCommandsError(
247+
f"{self._lineNum}, setting command: The keyboard shortcuts must be followed by a blank line. "
248+
"Multiple keys must be included in a table. "
249+
f"Erroneous key: {key}"
250+
)
251+
252+
# Finally, the next line should be the description.
253+
desc = self._seekNonEmptyLine()
254+
self._kcLines.append(f"| {name} | {' | '.join(keys)} | {desc} |")
255+
if not self._kcLines[-2].startswith("|"):
256+
# The previous line was not a table, so this is a new table.
257+
# Write the header row.
258+
numCols = len(keys) + 2 # name + description + layouts
259+
self._kcLines.append("|" + "---|" * numCols)

0 commit comments

Comments
 (0)