diff options
-rw-r--r-- | TODO.md | 2 | ||||
-rwxr-xr-x | browser.py | 36 | ||||
-rw-r--r-- | fsm.py | 174 | ||||
-rw-r--r-- | gemini.py | 16 | ||||
-rw-r--r-- | style.css | 29 | ||||
-rw-r--r-- | test.gmi | 49 |
6 files changed, 295 insertions, 11 deletions
@@ -3,7 +3,7 @@ - vim-style - scroll - highlight and follow links - - forward and back + - forward and bac ak - url bar - [ ] search in page - [ ] work with search pages @@ -2,7 +2,7 @@ import sys -from PySide2 import QtCore, QtWidgets +from PySide2 import QtCore, QtWidgets, QtGui import gemini @@ -28,13 +28,26 @@ class GViewport(QtWidgets.QTextBrowser): def loadResource(self, type_, url): gem = gemini.get(url.toString()) + print(gem['body']) if 'body' in gem: html = gemini.gem2html(gem['body']) else: html = '<h1>{} {}</h1>'.format(gem['status'], gem['meta']) - self.resourceLoadedStatus.emit('{status} {meta}'.format(status=gem['status'], meta=gem['meta'])) + self.resourceLoadedStatus.emit( + '{status} {meta}'.format(status=gem['status'], meta=gem['meta']) + ) return html + def setSource(self, url, type_=None): + """ + send all unsupported urls to the OS + """ + if url.scheme() == 'gemini': + if type_ is None: + return super().setSource(url) + return super().setSource(url, type_) + return QtGui.QDesktopServices().openUrl(url) + def setRawSource(self): return self.setSource(QtCore.QUrl(self.address_bar.text())) @@ -50,12 +63,10 @@ class GBrowser(QtWidgets.QMainWindow): def __init__(self): QtWidgets.QMainWindow.__init__(self) - self.init_ui() - def init_ui(self): - request_status = QtWidgets.QLabel() - self.statusBar().addWidget(request_status) + ## Create widgets + # Navigation Toolbar back = QtWidgets.QPushButton("back") forward = QtWidgets.QPushButton("forward") address = GUrlBar() @@ -64,8 +75,17 @@ class GBrowser(QtWidgets.QMainWindow): toolbar.addWidget(back) toolbar.addWidget(forward) toolbar.addWidget(address) + self.addToolBar(toolbar) + + # Main Viewport browser = GViewport(address) + self.setCentralWidget(browser) + # Status Bar + request_status = QtWidgets.QLabel() + self.statusBar().addWidget(request_status) + + # Connect signals back.clicked.connect(browser.backward) forward.clicked.connect(browser.forward) browser.sourceChanged.connect(address.setUrl) @@ -73,8 +93,8 @@ class GBrowser(QtWidgets.QMainWindow): browser.hoverUrlChanged.connect(self.set_status_url) browser.resourceLoadedStatus.connect(request_status.setText) - self.addToolBar(toolbar) - self.setCentralWidget(browser) + # + browser.document().setDefaultStyleSheet(open('style.css').read()) browser.setSource(QtCore.QUrl('gemini://gemini.circumlunar.space/')) @@ -0,0 +1,174 @@ +import sys + +class StackFSM(object): + """ + Implement a finite state machine that uses a stack to + manage state. + """ + + def __init__(self): + self._state_stack = [] + + def _current_state(self): + if len(self._state_stack) > 0: + return self._state_stack[-1] + return None + + def update(self): + fn = self._current_state() + if fn is not None: + fn() + + def push_state(self, state): + self._state_stack.append(state) + + def pop_state(self): + return self._state_stack.pop() + +class Parser(object): + + def __init__(self, document, output=None): + self._document = document + if output is None: + output = sys.stdout + self._output = output + self._offset = 0 + self._blanks = 0 + self._fsm = StackFSM() + + def parse(self): + self._fsm.push_state(self.text_state) + while self._fsm._current_state() is not None: + self._fsm.update() + + def text_state(self): + if len(self._document) <= self._offset: + self._fsm.pop_state() + return + line = self._document[self._offset] + if line.strip() == '': + self._blanks += 1 + else: + self._blanks = 0 + if line.strip() == '```': + self._fsm.pop_state() + self._fsm.push_state(self.pre_state) + self._output.write('<pre>\n') + self._offset += 1 + elif line.startswith('* '): + self._fsm.pop_state() + self._fsm.push_state(self.list_state) + self._output.write('<ul>\n') + elif line.startswith('=>'): + self._fsm.pop_state() + self._fsm.push_state(self.link_state) + self._output.write('<ul>\n') + else: + if line.startswith('# '): + self._output.write('<h1>{}</h1>\n'.format(line[2:])) + elif line.startswith('## '): + self._output.write('<h2>{}</h2>\n'.format(line[3:])) + elif line.startswith('### '): + self._output.write('<h3>{}</h3>\n'.format(line[4:])) + elif line.startswith('> '): + self._output.write('<blockquote>{}</blockquote>\n'.format(line[2:])) + elif line.strip() == '': + if self._blanks > 1: + self._output.write('<br/>\n') + else: + self._output.write('<p>{}</p>\n'.format(line)) + self._offset += 1 + + def pre_state(self): + if len(self._document) < self._offset: + self.pop_state() + return + line = self._document[self._offset] + if line.strip() == '```': + self._fsm.pop_state() + self._fsm.push_state(self.text_state) + self._output.write('</pre>\n') + self._offset += 1 + elif line.startswith('* '): + self._fsm.pop_state() + self._fsm.push_state(self.list_state) + self._output.write('<ul>\n') + elif line.startswith('=>'): + self._fsm.pop_state() + self._fsm.push_state(self.link_state) + self._output.write('<ul>\n') + else: + self._output.write(line + '\n') + self._offset += 1 + + def list_state(self): + if len(self._document) < self._offset: + self.pop_state() + return + line = self._document[self._offset] + if line.startswith('* '): + self._output.write('<li>{}</li>\n'.format(line[2:])) + self._offset += 1 + else: + self._fsm.pop_state() + self._fsm.push_state(self.text_state) + self._output.write('</ul>\n') + + def link_state(self): + if len(self._document) < self._offset: + self.pop_state() + return + line = self._document[self._offset] + if line.startswith('=>'): + parts = line[2:].split(None, 2) + if len(parts) == 1: + self._output.write('<li><a href="{}">{}</a></li>\n'.format(parts[0], parts[0])) + else: + self._output.write('<li><a href="{}">{}</a></li>\n'.format(parts[0], parts[1])) + self._offset += 1 + else: + self._fsm.pop_state() + self._fsm.push_state(self.text_state) + self._output.write('</ul>\n') + +document = """ +# h1 +hello +hello + +## h2 +``` +code +code +``` +### h3 +hello + + +hello + +### lists +* hello +* two +* three + +text + +* one +* two +* three + +### links +=>https://example.com hello +=> https://example.com two +=> https://example.com three + +text + +=>https://example.com +=> https://example.com +=> https://example.com +""" + +p = Parser(document.split('\n')) +p.parse() @@ -8,7 +8,7 @@ def htmlescape(text: str) -> str: def gem2html(gem: str) -> str: html = [] - html.append('<style>\n{css}</style>'.format(css=open('style.css').read())) + html.append('<html>\n<body>') state = 'text' blanklines = 0 for line in gem.split('\n'): @@ -69,6 +69,7 @@ def gem2html(gem: str) -> str: html.append('<h1>{}</h1>'.format(line[1:].lstrip())) else: html.append('<p>{}</p>'.format(htmlescape(line))) + html.append('</body>\n</html>') return '\n'.join(html) def urljoin(base: str, url: str) -> str: @@ -90,6 +91,16 @@ def get(url: str, follow_redirects: bool = True) -> dict: response = _get(response['meta']) return response +def _parse_meta(meta: str) -> dict: + mime, _, params_text = meta.lower().strip().partition(';') + params = {} + if params_text.strip(): + for param in params_text.split(';'): + k, val = param.split('=') + params[k.strip()] = val.strip() + params['mime'] = mime.strip() + return params + def _get(url: str) -> dict: url_parts = urllib.parse.urlsplit(url) if len(url_parts.path) == 0: @@ -118,9 +129,10 @@ def _get(url: str) -> dict: 'status': status, 'meta': meta.strip(), } + meta_params = _parse_meta(meta) body = fp.read() return { 'status': status, 'meta': meta.strip(), - 'body': body.decode('utf8'), + 'body': body.decode(meta_params.get('charset', 'utf8')), } @@ -1,3 +1,32 @@ +html { font: 100%/1.5 Arial, sans-serif; } +h1 { font-size: 2em; /* 2*16 = 32 */ } +h2 { font-size: 1.5em; /* 1.5*16 = 24 */ } +h3 { font-size: 1.17em; /* 1.17*16 = 18.72 */ } +h4 { font-size: 1em; /* 1*16 = 16 */ } +h5 { font-size: 0.83em; /* 0.83*16 = 13.28 */ } +h6 { font-size: 0.75em; /* 0.75*16 = 12 */ } +p { + line-height: 1.5; + margin: 0 0 1em; +} +a:active { color: tomato; } +a:focus { border: 1px dotted tomato; } +code, +pre { font-family: monospace, serif; +font-size: 1em; } +blockquote { + font-style: italic; +} +blockquote:before { + content: "\201C"; + display: inline-block; + padding-right: .4em; +} + +/* + * Begin custom styles. + */ + li.external a { color: red; } diff --git a/test.gmi b/test.gmi new file mode 100644 index 0000000..aca420f --- /dev/null +++ b/test.gmi @@ -0,0 +1,49 @@ +# Header Level 1 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. + +Quisque eget ultricies arcu, vel sollicitudin risus. + +## Header Level 2 + +Donec dictum est metus, id placerat nibh tempor id. + +Donec quis massa commodo, commodo orci elementum, mollis est. + +### Header Level 3 + +Ut ac finibus nisl. Aliquam aliquam felis eu dolor tempor, ut ultricies urna tempus. + +## Text + +Two blank lines follow. + + +Three blank lines follow. + + + +That's it! + +## Lists +* Mercury +* Gemini +* Apollo + +## Links + +=> https://example.com A cool website +=> gopher://example.com An even cooler gopherhole +=> gemini://example.com A supremely cool Gemini capsule +=> sftp://example.com + +## Blockquotes + +> Gemtext supports blockquotes. The quoted content is written as a single long line, which begins with a single > character + +## Preformatted text + +``` +Pre- formatted text +should be aligned +``` |