Compare commits

...

150 Commits

Author SHA1 Message Date
chris west d0b503ebcf mention `d` in CHANGELOG 1 year ago
chris west 38dff71ac0 prevent downloading internal pages, better error message 1 year ago
deadjakk b29ea2cee4 added help line 1 year ago
deadjakk cf2810af80 derived default filepath for download from url using parse_url 1 year ago
deadjakk 4a5328a14e added ability to save the current url to a filepath 1 year ago
chris west 3f2ae945db fix `NO_COLOR` support 1 year ago
chris west 0c970dcb3a chore: Release phetch version 1.2.0 2 years ago
chris west fab97539d9 update version 2 years ago
chris west 8eb15db192 tweak theme help file 2 years ago
chris west 2a55fc3763 next version is 1.2 2 years ago
drclaw d90d8bad45 Add Android release (aarch64) 2 years ago
chris west eb960a78cd basic color themes. for fun 2 years ago
chris west 751553c0fd colors default to white 2 years ago
chris west 6d7ea11598 theme tests 2 years ago
chris west fb1b3d2185 disable/enable raw mode when suspending/resuming 2 years ago
chris west 9baac849e5 thanks clippy 2 years ago
chris west 89b5febd4b wrong order 2 years ago
chris west 02cfc41aa2 theme the cursor 2 years ago
chris west fe1fbf5009 nope 2 years ago
chris west 8ebeb8dec9 add color themes 2 years ago
chris west 6861848217 add `scroll` config option and default to entire screen 2 years ago
chris west 4029fca177 add `R` keyboard shortcut to reload URL 2 years ago
chris west 79174e329f add -a/-A to README and manual 2 years ago
chris west 7eefac1c26 update CHANGELOG 2 years ago
chris west 9e6f1b46f0 update gh action dependency 2 years ago
chris west 9da392c04b ignore doctests, for now 2 years ago
chris west 7522427d71 appease clippy 2 years ago
chris west 105b737d1d
Merge pull request #32 from walksanatora/feat-autoplay
autoplay feature so that media is automatically played
2 years ago
walksanatora e587fad0e6 autoplay feature so that media is automatically played 2 years ago
kakuhen fee4e316e3 Update README.md
The latest release of phetch is now available on MacPorts.
2 years ago
chris west 6572bf7fca ctrl-e or just e 4 years ago
chris west 17fedeb262 add --encoding to --help 4 years ago
chris west 082d70cee7 (cargo-release) start next development iteration 1.1.1-dev 4 years ago
chris west e98b3ac129 (cargo-release) version 1.1.0 4 years ago
chris west 8ad88f32b9 explain why there's no menu support 4 years ago
chris west f3dff96429 factor out left margin maker 4 years ago
chris west 16034d184e wrap constrains line width when active 4 years ago
chris west dffaafd1fb update changelog 4 years ago
chris west 495e47b5ac fix longest line calculation 4 years ago
chris west 0edee37c44 v1.1.0-dev 4 years ago
chris west 8f7f74927e update changelog 4 years ago
chris west 6231044f58 update manual 4 years ago
chris west ad43179f19 mention wrap in default conf 4 years ago
chris west e6583a81f3 cache encoded response 4 years ago
chris west ba7d527165 try to wrap on - , . : ; or space 4 years ago
chris west bfdd01e22a stupid wrap_text() 4 years ago
chris west c2c5e1c086 add -w, --wrap to cli and config file 4 years ago
chris west e28a5469a4 basic wrap tests 4 years ago
chris west 02b368ae52 tweak formatting 4 years ago
chris west e5f0f22ec5 mention it's off by default 4 years ago
chris west bacacf032e update changelog 4 years ago
chris west 6825891b88 toggle global encoding on ctrl-e 4 years ago
chris west cf9e16650e share config so we can modify it in views 4 years ago
chris west c07656509f better error message 4 years ago
chris west fa3dd26db5 use config encoding's setting 4 years ago
chris west 40479908a8 encoding config option 4 years ago
chris west 29c10d8042 ignore conf 4 years ago
chris west 4f652a7209 --encoding cli flag 4 years ago
chris west 3d6e05c081 ctrl-e toggle encoding 4 years ago
chris west a5c645f402 cp437 test 4 years ago
chris west 400fe770a9 simplify status line & show cp439 in it 4 years ago
chris west 5df09aead9 add encoding() to View trait 4 years ago
chris west 41324a0ff3 CP437 support 4 years ago
chris west 06344f734a 📎📎📎 4 years ago
chris west 80314f6bd6 make test 4 years ago
chris west 252731d0de tweak release doc 4 years ago
chris west 995870b9d4 don't need you 4 years ago
chris west ffa2083740 add NO_COLOR support — https://no-color.org/ 4 years ago
chris west 5f232d8862 start phetch on `make debug` 4 years ago
chris west 537aa7fbbd don't enable tls or tor in `make debug` 4 years ago
chris west 993ce83800 fix clippyisms 4 years ago
chris west 3c057b56f9 -c flag wasn't eating its argument 4 years ago
chris west bba6b5c740 Add AUR badge 4 years ago
chris west 508c2b44cb (cargo-release) start next development iteration 1.0.8-dev 4 years ago
chris west 36c3489bb4 (cargo-release) version 1.0.7 4 years ago
chris west 689b999ac9 update changelog 4 years ago
chris west 38e660ccb6 also try all socket_addrs for tls and tor 4 years ago
chris west 015c7878cc try to connect to socket_addrs(), not just 1st 4 years ago
chris west c574844e62 (cargo-release) start next development iteration 1.0.7-dev 4 years ago
chris west a3987d7a15 (cargo-release) version 1.0.6 4 years ago
chris west 31b17f1e77 update changelog 4 years ago
chris west 1b02d330d9 fix reloading logic 4 years ago
chris west fce7b34b74 (cargo-release) start next development iteration 1.0.6-dev 4 years ago
chris west f891d07d34 (cargo-release) version 1.0.5 4 years ago
chris west 4e70fa60bb update changelog 4 years ago
chris west 6e55457f26 only redraw on F5, don't reload 4 years ago
chris west 5fb8f14586 Revert "F5 fully reloads page"
This reverts commit 2da3a20c4e.
4 years ago
chris west 31a50da8bd (cargo-release) start next development iteration 1.0.5-dev 4 years ago
chris west ac2accee5c (cargo-release) version 1.0.4 4 years ago
chris west eb4980e5db Add some "screenshots" to the repo:
- phetch running on a Pocket CHIP
  https://qoto.org/@freemo/104241949575009245

- phetch running on Android
  https://mastodon.sdf.org/@kas/103742126028881482

- phetch running on NetBSD
  https://www.unitedbsd.com/d/264-phetch

- another on NetBSD
  https://www.reddit.com/r/unixporn/comments/izipi4/frankenwm_vintage/
4 years ago
chris west d0ae999b6d update changelog 4 years ago
chris west 4be92bba10 Mention reload trick in manual 4 years ago
chris west 2da3a20c4e F5 fully reloads page 4 years ago
chris west 2fdb64c49f put benchmarks in sub-crate to cut down on dev deps 4 years ago
chris west 4d47287849 ctrl+u can reload the current page 4 years ago
chris west 053dcec764 phetch now available on NetBSD! 4 years ago
chris west 7585f70e38 typo 4 years ago
chris west c6d5b7a86d (cargo-release) start next development iteration 1.0.4-dev 4 years ago
chris west a3c04be5f7 (cargo-release) version 1.0.3 4 years ago
chris west 155b30cb28 update changelog on release 4 years ago
chris west 5f796bdee1 -m in changelog 4 years ago
chris west ac77420ba9 hook up --media flag 4 years ago
chris west 6fd90872e7 update manual 4 years ago
chris west b6cea29372 un-gate media feature 4 years ago
chris west 1359a4eb49 add -m, -M flags for media 4 years ago
chris west 596c8042ed use Line & LineIter instead of passing raw text around 4 years ago
chris west 9536db48ea get in line 4 years ago
chris west d814268d7e clear screen before launching media player 4 years ago
chris west 7157faf16d re-enable raw mode on media error 4 years ago
chris west f8c808e8f3 prompt before opening media player 4 years ago
chris west c1dc7a1b84 whitespace 4 years ago
chris west 67021ea036 mention media in README 4 years ago
chris west 65c8403e0e experimental media player support w/ `media` feature 4 years ago
chris west 88cc95c59f don't quit parent when child receives SIGINT 4 years ago
chris west cd350b15b9 Show unknown types as binary (downloads) 4 years ago
chris west 7fb209206a to_char always works 4 years ago
chris west 23c76e483b tiny test for new types 4 years ago
kim (grufwub) 81fcecb85d add new gopher item types + function check for media files
Signed-off-by: kim (grufwub) <grufwub@gmail.com>
4 years ago
chris west 6da14a7edc not anymore 4 years ago
chris west 0d2be4fb13 (cargo-release) start next development iteration 1.0.3-dev 4 years ago
chris west ebe2ebc2d1 (cargo-release) version 1.0.2 4 years ago
chris west 9270cca9e9 prep for release 4 years ago
chris west d19a74161b example 4 years ago
chris west e034c66a4f test color counting 4 years ago
chris west 3a0789035f colors work now 4 years ago
chris west 91ffcb459a dont need to peek 4 years ago
chris west 384cbe0939 only run through lines that have colors 4 years ago
chris west 575ebdbd01 ignore colors when calculating line length 4 years ago
chris west 05b78919cd use log! results 4 years ago
chris west 9d54fb370a yes it is 4 years ago
chris west 1146f42174 raw mode in main. fixes panics 4 years ago
chris west fb3e3940db enter raw mode on our own
termion wants you to pass around a RawTerminal
struct that wraps Stdout, which is probably "right"
but it's easier to do it the C way and treat stdout
as a shared global resource.
4 years ago
chris west 4e97579932 always shutdown() on drop() 4 years ago
chris west c002f2adc1 more idiomatic cli errors 4 years ago
chris west 3680edd856 still en route 4 years ago
chris west 9a635870d3 update changelog 4 years ago
chris west d925124807 resize on SIGWINCH 4 years ago
chris west 1b1d1fc8f3 use channels for keyboard events
this fixes the 'cancel download' bug
4 years ago
chris west 2ccd55dad7 note 4 years ago
chris west a11abe1bc8 aur and homebrew instructions 4 years ago
chris west 4899a9f4b8 lil reminder 4 years ago
chris west 385e88ee22 note on 1.0.1 4 years ago
chris west f81e514a83 (cargo-release) start next development iteration 1.0.2-dev 4 years ago
chris west a1e9d329a1 (cargo-release) version 1.0.1 4 years ago
chris west 7d36e8c7ca gh action: enable clippy component 4 years ago
chris west 88aae700d7 clippy 4 years ago
✨ Q (it/its) ✨ 8ce045ef16
Fix #13 4 years ago
chris west 3791c9c64f show actual gh release 4 years ago
chris west a36454f43b use footnotes 4 years ago
chris west b9e0c6be4f (cargo-release) start next development iteration 1.0.1-dev 4 years ago

@ -13,17 +13,17 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Cache cargo registry
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
@ -32,6 +32,7 @@ jobs:
with:
profile: minimal
toolchain: stable
components: clippy
override: true
- name: check
run: cargo check
@ -48,17 +49,17 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Cache cargo registry
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}

@ -3,7 +3,7 @@ name: Create Release
on:
push:
tags:
- 'v*.*.*'
- "v*.*.*"
jobs:
build_armv7:
@ -13,17 +13,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v2
- name: Cache cargo registry
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
@ -50,6 +50,50 @@ jobs:
name: phetch-linux-armv7
path: target/armv7-unknown-linux-gnueabihf/release/phetch-${{ steps.get_version.outputs.VERSION }}-linux-armv7.tgz
build_aarch64:
name: Build for android aarch64
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Cache cargo registry
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v3
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v3
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- name: Setup Toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: aarch64-linux-android
override: true
- name: Build release
uses: actions-rs/cargo@v1
with:
use-cross: true
command: build
args: --release --locked --target aarch64-linux-android
- name: Get current version
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: Package Binary
run: cp doc/phetch.1 target/aarch64-linux-android/release && cd target/aarch64-linux-android/release && tar zcvf phetch-${{ steps.get_version.outputs.VERSION }}-android-aarch64.tgz phetch phetch.1
- name: Upload Artifact
uses: actions/upload-artifact@v1
with:
name: phetch-android-aarch64
path: target/aarch64-linux-android/release/phetch-${{ steps.get_version.outputs.VERSION }}-android-aarch64.tgz
build_linux:
name: Build for Linux x86_64
runs-on: ubuntu-latest
@ -57,17 +101,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v2
- name: Cache cargo registry
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
@ -90,17 +134,17 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Cache cargo registry
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
@ -128,7 +172,7 @@ jobs:
create:
name: Create Release
needs: [build_armv7, build_linux, build_macos]
needs: [build_armv7, build_aarch64, build_linux, build_macos]
runs-on: ubuntu-latest
steps:
- name: Checkout
@ -148,15 +192,20 @@ jobs:
uses: actions/download-artifact@v1
with:
name: phetch-linux-armv7
- name: Download Android (aarch64) artifact
uses: actions/download-artifact@v1
with:
name: phetch-android-aarch64
- name: Create Release
uses: softprops/action-gh-release@v1
with:
draft: true
prerelease: true
prerelease: false
files: |
phetch-macos/phetch-${{ steps.get_version.outputs.VERSION }}-macos.zip
phetch-linux-x86_64/phetch-${{ steps.get_version.outputs.VERSION }}-linux-x86_64.tgz
phetch-linux-armv7/phetch-${{ steps.get_version.outputs.VERSION }}-linux-armv7.tgz
phetch-android-aarch64/phetch-${{ steps.get_version.outputs.VERSION }}-android-aarch64.tgz
body_path: CHANGELOG.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored

@ -2,4 +2,5 @@
**/*.rs.bk
phetch
phetch.log
.criterion/
.criterion/
conf

@ -1,3 +1,190 @@
## v1.2.1-dev
- Fix `NO_COLOR` support.
- The `d` keyboard shortcut will now download the current page to disk.
## v1.2.0
phetch is all about fun colors, but your options have always been
limited. You could turn off colors via `NO_COLOR` env variable or
you could leave them on. That's it.
Well, not anymore. As of `v1.2`, phetch now supports themes, in
addition to a few new config options.
### Config Options
The new config options in this release, for your convenience, are
as follows:
- `scroll` controls how many lines to jump by when paging up/down.
If set to 0 (the new default), you'll jump by an entire screen.
- `autoplay` controls whether you'll be prompted to play media files
or not. By default it's false, but one might find it handy to set
to `true` if hosting, say, a Gopher-powered music server.
### Keyboard Shortcuts
Just one, but it's a doozy - you can now reload the current URL by
pressing `R` (`shift+r`). Super handy when developing your own Gopherhole!
### Themes
As mentioned, themes are simple files with roughly the same format as
`phetch.conf`:
$ cat ~/.config/phetch/default.theme
# Color Scheme
## UI
ui.cursor white bold
ui.number magenta
ui.menu yellow
ui.text white
## Items
item.text cyan
item.menu blue
item.error red
item.search white
item.telnet grey
item.external green
item.download white underline
item.media green underline
item.unsupported whitebg red
Create your theme file and launch phetch with `-t FILE`, or set
the `theme FILE` option in your `~/.config/phetch/phetch.conf`
You can also set theme colors directly in your `phetch.conf`.
Learn more about themes, including which colors are available,
by opening phetch's on-line help: press `h` then `7` to get
there quickly.
For reference, we've included a few fun themes in the repo itself
that you can download and play with:
<https://github.com/xvxx/phetch/tree/master/themes>
## v1.1.0
Three new features in this release, plus an unknown number of new
bugs:
1. When the `NO_COLOR` env variable is set, phetch won't use colors
when rendering menus. See https://no-color.org/ for more information.
2. CP437 encoding support! You can toggle it on or off using `ctrl-e`
(for encoding) when viewing a Gopher text document, or using the
`--encoding` command line flag. See
https://en.wikipedia.org/wiki/Code_page_437.
Huge thanks to Kjell for suggesting this feature and providing some
great test data!
_NOTE: This only works for text documents since there's no `TAB`
character in CP437._
3. phetch now supports a primitive form of wrapping long lines when
rendering Gopher text documents. It won't reflow the text, but it
will make some phlogs and other documents slightly more readable.
Enable it with `--wrap NUM` or by adding `wrap NUM` to your
`phetch.conf`. You can disable it with `wrap 0`.
---
You may have run into long lines that don't break at the margins,
making the page hard to scroll and read:
![not wrapped](https://user-images.githubusercontent.com/41523880/97058194-f73d9d80-1541-11eb-8fc8-910489fafcc3.png)
Now, by either passing `--wrap NUM` or adding `wrap NUM` to your
`phetch.conf` file, phetch will attempt to wrap long lines at the
nearest punctation or space:
![wrapped](https://user-images.githubusercontent.com/41523880/97058201-fa388e00-1541-11eb-84ef-c539304870a6.png)
This is really useful if you want to browse, say, a directory of
Markdown files over Gopher. Modern Markdown is often written with the
assumption that the client will do the wrapping, so it can end up
looking pretty messy in an ananchronistic client like phetch. Reading
those files is now a bit easier:
| not wrapped | wrapped |
| -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| ![not wrapped](https://user-images.githubusercontent.com/41523880/97057857-1556ce00-1541-11eb-9cc1-6c6d438529ea.png) | ![wrapped](https://user-images.githubusercontent.com/41523880/97057869-1ee03600-1541-11eb-8e7b-ae47ff9ec871.png) |
This also works nicely on native Gopher content: phlog entries
sometimes have long URLs in their footnotes, and that could screw up
phetch's margin calculations.
Note that this doesn't do any _reflow_ of text, so documents with long
lines will still look a bit wonky, as you can see above. Some lines
will be too short. But it's a lot more usable, so we'll take it!
PS: You can use smaller values to get weird with it:
![weird](https://user-images.githubusercontent.com/41523880/97057878-269fda80-1541-11eb-9435-89f97cce8825.png)
Enjoy!
## v1.0.7
This release fixes https://github.com/xvxx/phetch/issues/19
phetch was aborting whenever it encountered a connection error
instead of trying the alternate socket addrs it was given.
Special thanks to @Ramiferous and @voidpin and **rvp**!
## v1.0.6
- More "reload" bugfixes.
## v1.0.5
Fix a crash introduced in 1.0.4.
## v1.0.4
- The `ctrl-u` and `ctrl-g` keyboard shortcuts can now be used
to reload the current page.
## v1.0.3
This release adds support for the `;` and `s` Gopher item types,
as well as the ability to play them in a media player - meaning
you can now run Gopher-powered media servers! As seen here:
https://twitter.com/grufwub/status/1264296292764856320
`mpv` is used by default, but you can specify a custom player
or disable the feature using the `-m` and `-M` flags. Info has
been added to `--help` and the phetch manual.
Special thanks to @grufwub for the feature request and getting
the code rolling!
Enjoy!
## v1.0.2
This release fixes a few small but irritating bugs:
- ANSI color codes now render properly. Full technicolor support.
Try it out: `phetch gopher://tilde.black/1/users/genin/`
- Resizing your terminal now resizes phetch automatically.
- Downloads can now be cancelled while in-progress with no funny
business.
- Debug information is now properly displayed when phetch crashes.
## v1.0.1
This is a small bugfix release. Thanks to @TheEnbyperor and @grufwub!
- phetch no longer panics on multibyte characters when trying to
truncate Gopher content.
## v1.0.0
`phetch` is now **v1.0.0**! Major thanks to @kseistrup for design,
@ -6,11 +193,11 @@ testing, and documentation, @iglosiggio for supporting [GILD][gild],
one year ago with his blog post, [Gopher: a present for
Redis](http://antirez.com/news/127).
-----
---
![phetch](https://raw.githubusercontent.com/xvxx/phetch/f1fe58d2483af1c64fa61aa46e5858b599f8e67b/img/start.png)
![phetch screen][phetch screen]
-----
---
`phetch` is a terminal Gopher client designed to help you quickly
navigate the gophersphere. With a snappy, text-based UI, Gopher types
@ -24,7 +211,7 @@ on how to install for Arch Linux with AUR (`yay phetch`), macOS with
homebrew (`brew install xvxx/code/phetch`), or how to build from
source.
-----
---
I have fond memories of using telnet to connect to the local library
when I was a kid, browsing their selection of books in an
@ -33,7 +220,7 @@ using some version of Windows, literally dialing into the library with
Hyperterminal.
<p align="center">
<img src="https://user-images.githubusercontent.com/41523880/75273938-b0cb4f80-57b6-11ea-90e8-f4d759bdbbc0.png" alt="library tui">
<img src="https://git.io/JvusG" alt="library tui">
</p>
It was futuristic. And, I thought, lost in the past. But Gopher, a
@ -44,11 +231,11 @@ The protocol is simple, constrained, and bursting with opportunity.
And while [MTV may not have an active Gopher server anymore][mtv], you
can easily run your own, or find a generous host like SDF or a tilde.
-----
---
![gopher menu in phetch](https://raw.githubusercontent.com/xvxx/phetch/3ec5e3f4335a5fdf709b5643da8aa4d5abe70815/img/dos.png)
![gopher menu in phetch][phetch menu]
-----
---
`phetch` is my attempt to bring a little bit of that retro-nostalgia
back into my terminal. Sure, I can acccess Gopher just fine using
@ -59,6 +246,8 @@ To get started just install and run `phetch`.
It's not perfect, but I've had fun using it, and I hope you do too!
[phetch screen]: https://raw.githubusercontent.com/xvxx/phetch/f1fe58d2483af1c64fa61aa46e5858b599f8e67b/img/start.png
[phetch menu]: https://raw.githubusercontent.com/xvxx/phetch/3ec5e3f4335a5fdf709b5643da8aa4d5abe70815/img/dos.png
[install]: README.md#installation
[gild]: https://github.com/xvxx/gild
[floodgap]: https://gopher.floodgap.com/gopher/

923
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
[package]
name = "phetch"
version = "1.0.0"
version = "1.2.0"
authors = ["chris west <c@xvxx.io>"]
license = "MIT"
edition = "2018"
@ -29,28 +29,15 @@ opt-level = 'z' # Optimize for size.
pre-release-replacements = [
{file="README.md", search="phetch-v\\d+\\.\\d+\\.\\d+-", replace="{{crate_name}}-v{{version}}-"},
{file="README.md", search="/v\\d+\\.\\d+\\.\\d+/", replace="/v{{version}}/"},
{file="CHANGELOG.md", search="\\d+\\.\\d+\\.\\d+-dev", replace="{{version}}"},
]
dev-version-ext = "dev"
[dev-dependencies]
criterion = "0.3.1"
[[bench]]
name = "parse_gopher"
harness = false
[[bench]]
name = "render_menu"
harness = false
[[bench]]
name = "render_text"
harness = false
[dependencies]
termion = "1.5.5"
libc = "0.2.66"
atty = "0.2.14"
lazy_static = "1.4"
cp437 = "0.1.1"
tor-stream = { version = "0.2.0", optional = true }
native-tls = { version = "0.2", optional = true }

@ -1,2 +1,4 @@
[target.armv7-unknown-linux-gnueabihf]
image = "rustembedded/cross:armv7-unknown-linux-gnueabihf-0.1.16"
[target.aarch64-linux-android]
image = "rustembedded/cross:aarch64-linux-android-0.1.16"

@ -20,18 +20,24 @@ release: $(PHETCH_RELEASE)
# Binary with debugging info
debug: $(PHETCH_DEBUG)
./target/debug/phetch
# Remove the release directory and its contents
clean:
@rm -rf target
# Run tests
test:
cargo clippy --all-features
cargo test --all-features
# Build the release version
$(PHETCH_RELEASE): $(RSFILES)
cargo build --release
# Build the debug version
$(PHETCH_DEBUG): $(RSFILES)
cargo build
cargo build --no-default-features
# Install phetch and its manual.
install: all

@ -6,11 +6,14 @@
|
--> <p align="center"> <img src="./img/logo.png"> <br>
<a href="https://git.io/JveQo">
<img src="https://img.shields.io/github/v/release/xvxx/phetch?include_prereleases">
<img src="https://img.shields.io/github/v/release/xvxx/phetch">
</a>
<a href="https://crates.io/crates/phetch">
<img src="https://img.shields.io/crates/v/phetch">
</a>
<a href="https://aur.archlinux.org/packages/phetch/">
<img src="https://img.shields.io/aur/version/phetch">
</a>
<a href="https://git.io/JvR5g">
<img src="https://github.com/xvxx/phetch/workflows/build/badge.svg">
</a>
@ -25,7 +28,7 @@ the gophersphere.
## features
- <1MB executable for Linux and Mac
- <1MB executable for Linux, Mac, and NetBSD
- Technicolor design (based on [GILD](https://github.com/xvxx/gild))
- No-nonsense keyboard navigation
- Supports Gopher searches, text and menu pages, and downloads
@ -36,6 +39,8 @@ the gophersphere.
## usage
Usage:
phetch [options] Launch phetch in interactive mode
phetch [options] url Open Gopher URL in interactive mode
@ -45,12 +50,21 @@ the gophersphere.
-o, --tor Use local Tor proxy to open all pages
-S, -O Disable TLS or Tor
-w, --wrap COLUMN Wrap long lines in "text" views at COLUMN.
-m, --media PROGRAM Use to open media files. Default: mpv
-M, --no-media Just download media files, don't download
-a, --autoplay Autoplay media files without prompting.
-A, --no-autoplay Prompt before playing media files.
-r, --raw Print raw Gopher response only
-p, --print Print rendered Gopher response only
-l, --local Connect to 127.0.0.1:7070
-e, --encoding Render text documents in CP437 or UTF8.
-c, --config FILE Use instead of ~/.config/phetch/phetch.conf
-C, --no-config Don't use any config file
-t, --theme FILE Use FILE for color theme or print current theme.
--print-theme Print current theme.
-h, --help Show this screen
-v, --version Show phetch version
@ -65,22 +79,33 @@ If you already have a Gopher client, download `phetch` here:
gopher://phkt.io/1/phetch/latest
On macOS you can install with [Homebrew](https://brew.sh/):
On macOS you can install with [Homebrew](https://brew.sh/)
or [MacPorts](https://macports.org).
If you're using Homebrew, open the Terminal and type:
brew install xvxx/code/phetch
For MacPorts:
sudo port install phetch
On Arch Linux, install phetch with your favorite [AUR helper][aur]:
yay phetch
Binaries for Linux, Raspberry Pi, and Mac are available at
On NetBSD, phetch is included in the main pkgsrc repo:
pkgin install phetch
Binaries for Linux, Raspberry Pi, Mac and Android (termux) are available at
https://github.com/xvxx/phetch/releases:
- [phetch-v1.0.0-linux-x86_64.tgz][0]
- [phetch-v1.0.0-linux-armv7.tgz (Raspberry Pi)][1]
- [phetch-v1.0.0-macos.zip][2]
- [phetch-v1.2.0-linux-x86_64.tgz][0]
- [phetch-v1.2.0-linux-armv7.tgz (Raspberry Pi)][1]
- [phetch-v1.2.0-macos.zip][2]
Just unzip/untar the `phetch` program into your $PATH and get going!
Just unzip/untar the `phetch` program into your `$PATH` and get going!
You can also build and install from source if you have `cargo`,
`make`, and the other dependencies described in the next section:
@ -89,6 +114,10 @@ You can also build and install from source if you have `cargo`,
cd phetch
env PREFIX=/usr/local make install
For Termux use:
env PREFIX=/data/data/com.termux/files/usr make install
## development
To build with TLS support on **Linux**, you need `openssl` and
@ -100,9 +129,9 @@ Regular development uses `cargo`:
cargo run -- <gopher-url>
*Pro-tip:* Run a local gopher server (like [phd][phd]) on
`127.0.0.1:7070` and start phetch with `-l` or `--local` to quickly
connect to it.
_Pro-tip:_ Run a local gopher server (like [phd]) on `0.0.0.0:7070`
and start phetch with `-l` or `--local` to quickly connect to it.
Useful for debugging!
phetch builds with TLS and Tor support by default. To disable these
features, or to enable only one of them, use the
@ -118,18 +147,21 @@ To enable just TLS support, or just Tor support, use `--features`:
cargo run --no-default-features --features tor -- gopher://phetch/about
## media player support
phetch includes support for opening video files (`;` item type) and
sound files (`s` item type) in [mpv] or an application of your choice
using the `-m` command line flag. To test it out, visit a compatible
Gopher server (maybe one using [Gophor]?). Or check out the "gopher
types" help page by pressing `ctrl-h` then `3` in phetch.
## todo
- [ ] catch SIGWINCH
- [ ] ctrl-c in load() not yet implemented
## bugs
- [ ] ctrl-c while telneting kills phetch
- [ ] ctrl-c in load() not yet implemented
- [ ] ctrl-c in download fails to return to listening state
because of termion bug:
https://gitlab.redox-os.org/redox-os/termion/issues/168
- [ ] gopher://tilde.black/1/users/genin/
- [ ] telnet IO seems broken after raw_input change (1146f42)
## future features
@ -141,8 +173,10 @@ To enable just TLS support, or just Tor support, use `--features`:
- [ ] bookmarks: toggle instead of just prepending to the file
- [ ] bookmarks: save the title of the current page
[0]: https://github.com/xvxx/phetch/releases/download/v1.0.0/phetch-v1.0.0-linux-x86_64.tgz
[1]: https://github.com/xvxx/phetch/releases/download/v1.0.0/phetch-v1.0.0-linux-armv7.tgz
[2]: https://github.com/xvxx/phetch/releases/download/v1.0.0/phetch-v1.0.0-macos.zip
[0]: https://github.com/xvxx/phetch/releases/download/v1.2.0/phetch-v1.2.0-linux-x86_64.tgz
[1]: https://github.com/xvxx/phetch/releases/download/v1.2.0/phetch-v1.2.0-linux-armv7.tgz
[2]: https://github.com/xvxx/phetch/releases/download/v1.2.0/phetch-v1.2.0-macos.zip
[phd]: https://github.com/xvxx/phd
[aur]: https://wiki.archlinux.org/index.php/AUR_helpers
[mpv]: https://github.com/mpv-player/mpv
[gophor]: https://github.com/grufwub/gophor

@ -0,0 +1,5 @@
/target
**/*.rs.bk
phetch
phetch.log
.criterion/

918
benchmarks/Cargo.lock generated

@ -0,0 +1,918 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi",
"libc",
"winapi 0.3.9",
]
[[package]]
name = "autocfg"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
[[package]]
name = "bitflags"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
[[package]]
name = "bstr"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31accafdb70df7871592c058eca3985b71104e15ac32f64706022c58867da931"
dependencies = [
"lazy_static",
"memchr",
"regex-automata",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e8c087f005730276d1096a652e92a8bacee2e2472bcc9715a74d2bec38b5820"
[[package]]
name = "byteorder"
version = "1.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de"
[[package]]
name = "cast"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9434b9a5aa1450faa3f9cb14ea0e8c53bb5d2b3c1bfd1ab4fc03e9f33fbfb0"
dependencies = [
"rustc_version",
]
[[package]]
name = "cc"
version = "1.0.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef611cc68ff783f18535d77ddd080185275713d852c4f5cbb6122c462a7a825c"
[[package]]
name = "cfg-if"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
[[package]]
name = "clap"
version = "2.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002"
dependencies = [
"bitflags",
"textwrap",
"unicode-width",
]
[[package]]
name = "core-foundation"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac"
[[package]]
name = "criterion"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70daa7ceec6cf143990669a04c7df13391d55fb27bd4079d252fca774ba244d8"
dependencies = [
"atty",
"cast",
"clap",
"criterion-plot",
"csv",
"itertools",
"lazy_static",
"num-traits",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_cbor",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022feadec601fba1649cfa83586381a4ad31c6bf3a9ab7d408118b05dd9889d"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-channel"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b153fe7cbef478c567df0f972e02e6d736db11affe43dfc9c56a9374d1adfb87"
dependencies = [
"crossbeam-utils",
"maybe-uninit",
]
[[package]]
name = "crossbeam-deque"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f02af974daeee82218205558e51ec8768b48cf524bd01d550abe5573a608285"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
"maybe-uninit",
]
[[package]]
name = "crossbeam-epoch"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "058ed274caafc1f60c4997b5fc07bf7dc7cca454af7c6e81edffe5f33f70dace"
dependencies = [
"autocfg",
"cfg-if",
"crossbeam-utils",
"lazy_static",
"maybe-uninit",
"memoffset",
"scopeguard",
]
[[package]]
name = "crossbeam-utils"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3c7c73a2d1e9fc0886a08b93e98eb643461230d5f1925e4036204d5f2e261a8"
dependencies = [
"autocfg",
"cfg-if",
"lazy_static",
]
[[package]]
name = "csv"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00affe7f6ab566df61b4be3ce8cf16bc2576bca0963ceb0955e45d514bf9a279"
dependencies = [
"bstr",
"csv-core",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "csv-core"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90"
dependencies = [
"memchr",
]
[[package]]
name = "either"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "getrandom"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc587bc0ec293155d5bfa6b9891ec18a1e330c234f896ea47fbada4cadbe47e6"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "half"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d36fab90f82edc3c747f9d438e06cf0a491055896f2a279638bb5beed6c40177"
[[package]]
name = "hermit-abi"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c30f6d0bc6b00693347368a67d41b58f2fb851215ff1da49e90fe2c5c667151"
dependencies = [
"libc",
]
[[package]]
name = "itertools"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6"
[[package]]
name = "js-sys"
version = "0.3.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca059e81d9486668f12d455a4ea6daa600bd408134cd17e3d3fb5a32d1f016f8"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f96b10ec2560088a8e76961b00d47107b3a625fecb76dedb29ee7ccbf98235"
[[package]]
name = "log"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b"
dependencies = [
"cfg-if",
]
[[package]]
name = "maybe-uninit"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00"
[[package]]
name = "memchr"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400"
[[package]]
name = "memoffset"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "043175f069eda7b85febe4a74abbaeff828d9f8b448515d3151a14a3542811aa"
dependencies = [
"autocfg",
]
[[package]]
name = "native-tls"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b0d88c06fe90d5ee94048ba40409ef1d9315d86f6f38c2efdaad4fb50c58b2d"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "num-traits"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac267bcc07f48ee5f8935ab0d24f316fb722d7a1292e2913f0cc196b29ffd611"
dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "numtoa"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"
[[package]]
name = "oorandom"
version = "11.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a170cebd8021a008ea92e4db85a72f80b35df514ec664b296fdcbb654eac0b2c"
[[package]]
name = "openssl"
version = "0.10.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d575eff3665419f9b83678ff2815858ad9d11567e082f5ac1814baba4e2bcb4"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"lazy_static",
"libc",
"openssl-sys",
]
[[package]]
name = "openssl-probe"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de"
[[package]]
name = "openssl-sys"
version = "0.9.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a842db4709b604f0fe5d1170ae3565899be2ad3d9cbc72dedc789ac0511f78de"
dependencies = [
"autocfg",
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "phetch"
version = "1.0.4-dev"
dependencies = [
"atty",
"lazy_static",
"libc",
"native-tls",
"termion",
"tor-stream",
]
[[package]]
name = "phetch-benchmarks"
version = "0.1.0"
dependencies = [
"criterion",
"phetch",
]
[[package]]
name = "pkg-config"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d36492546b6af1463394d46f0c834346f31548646f6ba10849802c9c9a27ac33"
[[package]]
name = "plotters"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d1685fbe7beba33de0330629da9d955ac75bd54f33d7b79f9a895590124f6bb"
dependencies = [
"js-sys",
"num-traits",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "ppv-lite86"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c36fa947111f5c62a733b652544dd0016a43ce89619538a8ef92724a6f501a20"
[[package]]
name = "proc-macro2"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51ef7cd2518ead700af67bf9d1a658d90b6037d77110fd9c0445429d0ba1c6c9"
dependencies = [
"unicode-xid",
]
[[package]]
name = "quote"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
dependencies = [
"getrandom",
"libc",
"rand_chacha",
"rand_core",
"rand_hc",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
dependencies = [
"getrandom",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
dependencies = [
"rand_core",
]
[[package]]
name = "rayon"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfd016f0c045ad38b5251be2c9c0ab806917f82da4d36b2a327e5166adad9270"
dependencies = [
"autocfg",
"crossbeam-deque",
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8c4fec834fb6e6d2dd5eece3c7b432a52f0ba887cf40e595190c4107edc08bf"
dependencies = [
"crossbeam-channel",
"crossbeam-deque",
"crossbeam-utils",
"lazy_static",
"num_cpus",
]
[[package]]
name = "redox_syscall"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce"
[[package]]
name = "redox_termios"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e891cfe48e9100a70a3b6eb652fef28920c117d366339687bd5576160db0f76"
dependencies = [
"redox_syscall",
]
[[package]]
name = "regex"
version = "1.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3780fcf44b193bc4d09f36d2a3c87b251da4a046c87795a0d35f4f927ad8e6"
dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae1ded71d66a4a97f5e961fd0cb25a5f366a42a41570d16a763a69c092c26ae4"
dependencies = [
"byteorder",
]
[[package]]
name = "regex-syntax"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26412eb97c6b088a6997e05f69403a802a92d520de2f8e63c2b65f9e0f47c4e8"
[[package]]
name = "remove_dir_all"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "rustc_version"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a"
dependencies = [
"semver",
]
[[package]]
name = "ryu"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f05ba609c234e60bee0d547fe94a4c7e9da733d1c962cf6e59efa4cd9c8bc75"
dependencies = [
"lazy_static",
"winapi 0.3.9",
]
[[package]]
name = "scopeguard"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "security-framework"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64808902d7d99f78eaddd2b4e2509713babc3dc3c85ad6f4c447680f3c01e535"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17bf11d99252f512695eb468de5516e5cf75455521e69dfe343f3b74e4748405"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403"
dependencies = [
"semver-parser",
]
[[package]]
name = "semver-parser"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
[[package]]
name = "serde"
version = "1.0.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96fe57af81d28386a513cbc6858332abc6117cfdb5999647c6444b8f43a370a5"
[[package]]
name = "serde_cbor"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e18acfa2f90e8b735b2836ab8d538de304cbb6729a7360729ea5a895d15a622"
dependencies = [
"half",
"serde",
]
[[package]]
name = "serde_derive"
version = "1.0.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f630a6370fd8e457873b4bd2ffdae75408bc291ba72be773772a4c2a065d9ae8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "164eacbdb13512ec2745fb09d51fd5b22b0d65ed294a1dcf7285a360c80a675c"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "socks"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6a64cfa9346d26e836a49fcc1ddfcb4d3df666b6787b6864db61d4918e1cbc2"
dependencies = [
"byteorder",
"libc",
"winapi 0.2.8",
"ws2_32-sys",
]
[[package]]
name = "syn"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c51d92969d209b54a98397e1b91c8ae82d8c87a7bb87df0b29aa2ad81454228"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]]
name = "tempfile"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9"
dependencies = [
"cfg-if",
"libc",
"rand",
"redox_syscall",
"remove_dir_all",
"winapi 0.3.9",
]
[[package]]
name = "termion"
version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c22cec9d8978d906be5ac94bceb5a010d885c626c4c8855721a4dbd20e3ac905"
dependencies = [
"libc",
"numtoa",
"redox_syscall",
"redox_termios",
]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
]
[[package]]
name = "tinytemplate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d3dc76004a03cec1c5932bca4cdc2e39aaa798e3f82363dd94f9adf6098c12f"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tor-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5865109fc90e0bc0f8c299f3794ca0fd5771df988aa6b962d4c9129c39674746"
dependencies = [
"lazy_static",
"socks",
]
[[package]]
name = "unicode-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3"
[[package]]
name = "unicode-xid"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
[[package]]
name = "vcpkg"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6454029bf181f092ad1b853286f23e2c507d8e8194d01d92da4a55c274a5508c"
[[package]]
name = "walkdir"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "777182bc735b6424e1a57516d35ed72cb8019d85c8c9bf536dccb3445c1a2f7d"
dependencies = [
"same-file",
"winapi 0.3.9",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.9.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
[[package]]
name = "wasm-bindgen"
version = "0.2.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac64ead5ea5f05873d7c12b545865ca2b8d28adfc50a49b84770a3a97265d42"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f22b422e2a757c35a73774860af8e112bff612ce6cb604224e8e47641a9e4f68"
dependencies = [
"bumpalo",
"lazy_static",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b13312a745c08c469f0b292dd2fcd6411dba5f7160f593da6ef69b64e407038"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f249f06ef7ee334cc3b8ff031bfc11ec99d00f34d86da7498396dc1e3b1498fe"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d649a3145108d7d3fbcde896a468d1bd636791823c9921135218ad89be08307"
[[package]]
name = "web-sys"
version = "0.3.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bf6ef87ad7ae8008e15a355ce696bed26012b7caa21605188cfd8214ab51e2d"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-build"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc"
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "ws2_32-sys"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e"
dependencies = [
"winapi 0.2.8",
"winapi-build",
]

@ -0,0 +1,23 @@
[package]
name = "phetch-benchmarks"
version = "0.1.0"
authors = ["chris west <c@xvxx.io>"]
edition = "2018"
[dev-dependencies]
criterion = "0.3.1"
[[bench]]
name = "parse_gopher"
harness = false
[[bench]]
name = "render_menu"
harness = false
[[bench]]
name = "render_text"
harness = false
[dependencies]
phetch = { path = ".." }

@ -0,0 +1,3 @@
fn main() {
println!("Run `cargo bench`");
}

@ -1,10 +1,11 @@
.\" Generated by scdoc 1.10.0
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "PHETCH" "1" "2020-01-12"
.TH "PHETCH" "1" "2022-11-14"
.P
.SH NAME
.P
@ -17,139 +18,182 @@ phetch - quick lil gopher client
.SH DESCRIPTION
.P
\fBphetch\fR is a terminal client designed to help you quickly navigate
the gophersphere. It features non-nonsense keyboard navigation,
the gophersphere.\& It features non-nonsense keyboard navigation,
support for most Gopher features, easy-to-use TLS and Tor support, as
well as bookmarking and history features.
well as bookmarking and history features.\&
.P
Usually \fBphetch\fR is started with a Gopher URL:
.P
.RS 4
phetch gopher://some-gopher-url.com
phetch gopher://some-gopher-url.\&com
.P
.RE
If no URL is given, however, \fBphetch\fR will launch and open its default
"start page". This can be configured to be any URL. (See \fBCONFIG\fR.)
"start page".\& This can be configured to be any URL.\& (See \fBCONFIG\fR.\&)
.P
.SH OPTIONS
.P
\fB-l\fR, \fB--local\fR
.RS 4
Connect to the local Gopher server at URL \fI127.0.0.1:7070\fR.
Connect to the local Gopher server at URL \fI127.\&0.\&0.\&1:7070\fR.\&
.P
.RE
\fB-p\fR \fIURL\fR, \fB--print\fR \fIURL\fR
.RS 4
Print a rendered Gopher server response of \fIURL\fR and exit.
Print a rendered Gopher server response of \fIURL\fR and exit.\&
.P
.RE
\fB-r\fR \fIURL\fR, \fB--raw\fR \fIURL\fR
.RS 4
Print the raw Gopher server response of \fIURL\fR and exit.
Print the raw Gopher server response of \fIURL\fR and exit.\&
.P
.RE
\fB-s\fR, \fB--tls\fR
.RS 4
Attempt to fetch all pages securely over TLS.
Attempt to fetch all pages securely over TLS.\&
.P
.RE
\fB-S\fR, \fB--no-tls\fR
.RS 4
Do not use TLS for connections. This can be used to cancel out an
option set in the config file, for instance.
Do not use TLS for connections.\& This can be used to cancel out an
option set in the config file, for instance.\&
.P
.RE
\fB-o\fR, \fB--tor\fR
.RS 4
Make all connections using a local Tor proxy.
Tor is The Onion Router.
Make all connections using a local Tor proxy.\&
Tor is The Onion Router.\&
Set the TOR_PROXY env variable to use an address other than the
Tor default of 127.0.0.1:9050.
Tor default of 127.\&0.\&0.\&1:9050.\&
.P
.RE
\fB-O\fR, \fB--no-tor\fR
.RS 4
Disable Tor.
Disable Tor.\&
.P
.RE
\fB-w\fR, \fB--wrap\fR \fICOLUMN\fR
.RS 4
Wrap long lines in Gopher "text" views at \fICOLUMN\fR.\&
Default: 0 (off)
.P
.RE
\fB-m\fR, \fB--media\fR \fIPATH\fR
.RS 4
Use program at \fIPATH\fR to open media files (movies and sounds).\&
Default: mpv
.P
.RE
\fB-M\fR, \fB--no-media\fR
.RS 4
Don'\&t try to open media files.\& Download them like regular binary
Gopher items.\&
.P
.RE
\fB-a\fR, \fB--autoplay\fR
.RS 4
Autoplay media files instead of prompting.\&
.P
.RE
\fB-A\fR, \fB--no-autoplay\fR
.RS 4
Don'\&t autoplay media files.\& Prompt instead.\&
.P
.RE
\fB-c\fR, \fB--config\fR \fIFILE\fR
.RS 4
Use \fIFILE\fR instead of \fI~/.config/phetch/phetch.conf\fR
Use \fIFILE\fR instead of \fI~/.\&config/phetch/phetch.\&conf\fR
.P
.RE
\fB-C\fR, \fB--no-config\fR
.RS 4
Do not use any config file.
Do not use any config file.\&
.P
.RE
\fB-t\fR, \fB--theme\fR \fIFILE\fR
.RS 4
Use \fIFILE\fR for color theme.\&
.P
.RE
\fB--print-theme\fR
.RS 4
Print current theme.\&
.P
.RE
\fB-e\fR, \fB--encoding\fR \fIENCODING\fR
.RS 4
Render text views in CP437 or UTF8 (default) encoding.\&
.P
.RE
\fB-h\fR, \fB--help\fR
.RS 4
Print a help summary and exit.
Print a help summary and exit.\&
.P
.RE
\fB-v\fR, \fB--version\fR
.RS 4
Print version information and exit.
Print version information and exit.\&
.P
.RE
.SH NOTES
.P
When given a \fIURL\fR, \fBphetch\fR will show the requested Gopher page and
enter interactive mode.
enter interactive mode.\&
.P
Without a \fIURL\fR, \fBphetch\fR will show a builtin dashboard with easy
access to online help, bookmarks and history, and enter interactive
mode.
mode.\&
.P
Command line options always override options set in phetch.conf.
Command line options always override options set in phetch.\&conf.\&
.P
.SH NAVIGATION
.P
.SS KEYBOARD SHORTCUTS
.P
All single letter commands also work with the \fBCtrl\fR key: e.g., \fBh\fR
and \fBCtrl-h\fR are synonyms.
All single letter commands also work with the \fBCtrl\fR key: e.\&g.\&, \fBh\fR
and \fBCtrl-h\fR are synonyms.\&
.P
\fBh\fR
.RS 4
Go to builtin help page.
Go to builtin help page.\&
.RE
\fBq\fR
.RS 4
Quit \fBphetch\fR.
Quit \fBphetch\fR.\&
.P
.RE
\fBleft arrow\fR
.RS 4
Go back in history.
Go back in history.\&
.RE
\fBright arrow\fR
.RS 4
Go forward in history.
Go forward in history.\&
.RE
\fBup arrow\fR, \fBp\fR, \fBk\fR
.RS 4
Select previous link.
Select previous link.\&
.RE
\fBdown arrow\fR, \fBn\fR, \fBj\fR
.RS 4
Select next link.
Select next link.\&
.RE
\fBPgUp\fR, \fB-\fR
.RS 4
Scroll up by many lines.
Scroll up by many lines.\&
.RE
\fBPgDn\fR, \fBSPACE\fR
.RS 4
Scroll down by many lines.
Scroll down by many lines.\&
.P
.RE
\fBNumber key\fR
.RS 4
Open/select link.
Open/select link.\&
.RE
\fBEnter\fR
.RS 4
Open current link.
Open current link.\&
.RE
\fBEsc\fR, \fBCtrl-c\fR
.RS 4
@ -158,42 +202,50 @@ Cancel
.RE
\fBf\fR, \fB/\fR
.RS 4
Find link in page.
Find link in page.\&
.P
.RE
\fBg\fR
.RS 4
Go to Gopher URL.
Go to Gopher URL.\&
.RE
\fBR\fR
.RS 4
Reload current URL.\&
.RE
\fBu\fR
.RS 4
Edit URL.
Edit URL.\&
.RE
\fBy\fR
.RS 4
Copy URL.
Copy URL.\&
.P
.RE
\fBb\fR
.RS 4
Show bookmarks.
Show bookmarks.\&
.RE
\fBs\fR
.RS 4
Save bookmark.
Save bookmark.\&
.RE
\fBa\fR
.RS 4
Show history. (Mnemonic: \fBAll\fR pages/history)
Show history.\& (Mnemonic: \fBAll\fR pages/history)
.P
.RE
\fBr\fR
.RS 4
View raw source.
View raw source.\&
.RE
\fBw\fR
.RS 4
Toggle wide mode.
Toggle wide mode.\&
.RE
\fBe\fR
.RS 4
Toggle encoding between UTF8 and CP437.\&
.P
.RE
.SS MENU NAVIGATION
@ -201,24 +253,24 @@ Toggle wide mode.
Up and down arrows
.RS 4
Use the up and down arrows, \fBj\fR and \fBk\fR keys, or \fBn\fR and \fBp\fR
keys to select links. \fBphetch\fR will scroll for you, or you can
keys to select links.\& \fBphetch\fR will scroll for you, or you can
use page up and page down (or \fB-\fR and spacebar) to scroll by
many lines at once.
many lines at once.\&
.P
.RE
Number keys
.RS 4
If there are few enough menu items, pressing a number key will
open a link. Otherwise, the first matching number will be
selected. Use \fBEnter\fR to open the selected link.
open a link.\& Otherwise, the first matching number will be
selected.\& Use \fBEnter\fR to open the selected link.\&
.P
.RE
Incremental search
.RS 4
Press \fBf\fR or \fB/\fR to activate search mode, then just start
typing. \fBphetch\fR will look for the first case-insensitive match
and try to select it. Use arrow keys or \fBCtrl-p\fR/\fBCtrl-n\fR to cycle
through matches.
typing.\& \fBphetch\fR will look for the first case-insensitive match
and try to select it.\& Use arrow keys or \fBCtrl-p\fR/\fBCtrl-n\fR to cycle
through matches.\&
.P
.RE
.SH BOOKMARKS
@ -227,44 +279,44 @@ There are two ways to save the URL of the current page:
.P
\fBy\fR
.RS 4
Copy URL.
Copy URL.\&
.RE
\fBs\fR
.RS 4
Save bookmark.
Save bookmark.\&
.P
.RE
Bookmarks will be saved to the file \fI~/.config/phetch/bookmarks.gph\fR if
the directory \fI~/.config/phetch/\fR exists.
Bookmarks will be saved to the file \fI~/.\&config/phetch/bookmarks.\&gph\fR if
the directory \fI~/.\&config/phetch/\fR exists.\&
.P
\fBb\fR
.RS 4
View saved bookmarks.
View saved bookmarks.\&
.P
.RE
The clipboard function uses \fBpbcopy\fR on MacOS, and \fBxsel\fR \fI-sel clip\fR
on Linux.
on Linux.\&
.P
.SH HISTORY
.P
If you create a \fIhistory.gph\fR file in \fI~/.config/phetch/\fR, each Gopher
URL you open will be stored there.
If you create a \fIhistory.\&gph\fR file in \fI~/.\&config/phetch/\fR, each Gopher
URL you open will be stored there.\&
.P
New URLs are appended to the bottom, but loaded in reverse order, so
you'll see all the most recently visited pages first when you press
the \fBa\fR key.
you'\&ll see all the most recently visited pages first when you press
the \fBa\fR key.\&
.P
Feel free to edit your history file directly, or share it with your
friends!
friends!\&
.P
.SH CONFIG
.P
If you create a \fIphetch.conf\fR file in \fI~/.config/phetch/\fR, it will be
automatically loaded when \fBphetch\fR starts. The config file supports
If you create a \fIphetch.\&conf\fR file in \fI~/.\&config/phetch/\fR, it will be
automatically loaded when \fBphetch\fR starts.\& The config file supports
most command line options, for your convenience, as well as a few ways
to customize your browsing experience. For example, \fBphetch\fR will
to customize your browsing experience.\& For example, \fBphetch\fR will
always launch in TLS mode if `tls yes` appears in the config file --
no need to pass `--tls` or `-t` on startup.
no need to pass `--tls` or `-t` on startup.\&
.P
Here is an example config with all options:
.P
@ -282,19 +334,114 @@ tor no
# Always start in wide mode\&.
wide no
# Program to use to open media files\&.
media mpv
# Use emoji indicators for TLS & Tor\&.
emoji no
# Encoding\&. Only CP437 and UTF8 are supported\&.
encoding utf8
# Wrap text at N columns\&. 0 = off (--wrap)
wrap 0
# How many lines to page up/down by? 0 = full screen
scroll 0
# Path to theme file, if you want to use one
theme ~/\&.config/phetch/dark\&.theme
.fi
.RE
.P
.SH THEMES
.P
You can change phetch'\&s color scheme by supplying your own theme
file with `--theme`/`-t` or by setting `theme FILE` in your
phetch.\&conf.\&
.P
You can also view the current theme with:
.P
.RS 4
$ phetch --print-theme
.P
.RE
Theme files look like this:
.P
.nf
.RS 4
ui\&.cursor white bold
ui\&.number magenta
ui\&.menu yellow
ui\&.text white
item\&.text cyan
item\&.menu blue
item\&.error red
item\&.search white
item\&.telnet grey
item\&.external green
item\&.download white underline
item\&.media green underline
item\&.unsupported whitebg red
.fi
.RE
.P
Valid colors for use in phetch themes:
.P
.nf
.RS 4
bold
underline
grey
red
green
yellow
blue
magenta
cyan
white
black
darkred
darkgreen
darkyellow
darkblue
darkmagenta
darkcyan
darkwhite
blackbg
redbg
greenbg
yellowbg
bluebg
magentabg
cyanbg
whitebg
.fi
.RE
.P
.SH MEDIA PLAYER SUPPORT
.P
\fBphetch\fR includes support for opening video files (`;` item type) and
sound files (`s` item type) in `mpv` or an application of your choice
using the `-m` command line flag.\& To test it out, visit a compatible
Gopher server or check out the "gopher types" help page by lauching
\fBphetch\fR and then pressing `ctrl-h` then `3`.\&
.P
By default \fBphetch\fR will prompt you when you try to open a media file,
but you can change this behavior by starting it with `--autoplay`/`-a`
or by setting `autoplayer true` in your config file.\&
.P
.SH ABOUT
.P
\fBphetch\fR is maintained by chris west, and released under the MIT license.
\fBphetch\fR is maintained by chris west, and released under the MIT license.\&
.P
phetch's Gopher hole:
phetch'\&s Gopher hole:
.RS 4
\fIgopher://phkt.io/1/phetch\fR
\fIgopher://phkt.\&io/1/phetch\fR
.RE
phetch's webpage:
phetch'\&s webpage:
.RS 4
\fIhttps://github.com/xvxx/phetch\fR
\fIhttps://github.\&com/xvxx/phetch\fR

@ -49,12 +49,39 @@ If no URL is given, however, *phetch* will launch and open its default
*-O*, *--no-tor*
Disable Tor.
*-w*, *--wrap* _COLUMN_
Wrap long lines in Gopher "text" views at _COLUMN_.
Default: 0 (off)
*-m*, *--media* _PATH_
Use program at _PATH_ to open media files (movies and sounds).
Default: mpv
*-M*, *--no-media*
Don't try to open media files. Download them like regular binary
Gopher items.
*-a*, *--autoplay*
Autoplay media files instead of prompting.
*-A*, *--no-autoplay*
Don't autoplay media files. Prompt instead.
*-c*, *--config* _FILE_
Use _FILE_ instead of _~/.config/phetch/phetch.conf_
*-C*, *--no-config*
Do not use any config file.
*-t*, *--theme* _FILE_
Use _FILE_ for color theme.
*--print-theme*
Print current theme.
*-e*, *--encoding* _ENCODING_
Render text views in CP437 or UTF8 (default) encoding.
*-h*, *--help*
Print a help summary and exit.
@ -109,6 +136,8 @@ and *Ctrl-h* are synonyms.
*g*
Go to Gopher URL.
*R*
Reload current URL.
*u*
Edit URL.
*y*
@ -125,6 +154,8 @@ and *Ctrl-h* are synonyms.
View raw source.
*w*
Toggle wide mode.
*e*
Toggle encoding between UTF8 and CP437.
## MENU NAVIGATION
@ -169,7 +200,7 @@ If you create a _history.gph_ file in _~/.config/phetch/_, each Gopher
URL you open will be stored there.
New URLs are appended to the bottom, but loaded in reverse order, so
you'll see all the most recently visited pages first when you press
you'll see all the most recently visited pages first when you press
the *a* key.
Feel free to edit your history file directly, or share it with your
@ -199,10 +230,99 @@ tor no
# Always start in wide mode.
wide no
# Program to use to open media files.
media mpv
# Use emoji indicators for TLS & Tor.
emoji no
# Encoding. Only CP437 and UTF8 are supported.
encoding utf8
# Wrap text at N columns. 0 = off (--wrap)
wrap 0
# How many lines to page up/down by? 0 = full screen
scroll 0
# Path to theme file, if you want to use one
theme ~/.config/phetch/dark.theme
```
# THEMES
You can change phetch's color scheme by supplying your own theme
file with `--theme`/`-t` or by setting `theme FILE` in your
phetch.conf.
You can also view the current theme with:
$ phetch --print-theme
Theme files look like this:
```
ui.cursor white bold
ui.number magenta
ui.menu yellow
ui.text white
item.text cyan
item.menu blue
item.error red
item.search white
item.telnet grey
item.external green
item.download white underline
item.media green underline
item.unsupported whitebg red
```
Valid colors for use in phetch themes:
```
bold
underline
grey
red
green
yellow
blue
magenta
cyan
white
black
darkred
darkgreen
darkyellow
darkblue
darkmagenta
darkcyan
darkwhite
blackbg
redbg
greenbg
yellowbg
bluebg
magentabg
cyanbg
whitebg
```
# MEDIA PLAYER SUPPORT
*phetch* includes support for opening video files (`;` item type) and
sound files (`s` item type) in `mpv` or an application of your choice
using the `-m` command line flag. To test it out, visit a compatible
Gopher server or check out the "gopher types" help page by lauching
*phetch* and then pressing `ctrl-h` then `3`.
By default *phetch* will prompt you when you try to open a media file,
but you can change this behavior by starting it with `--autoplay`/`-a`
or by setting `autoplayer true` in your config file.
# ABOUT
*phetch* is maintained by chris west, and released under the MIT license.

@ -0,0 +1,12 @@
# phetch release process
- update CHANGELOG.md
- $ make test
- $ cargo release --prev-tag-name=v1.x.x -n
- $ cargo release --prev-tag-name=v1.x.x
- edit https://github.com/xvxx/phetch/releases
- $ cd ../homebrew-code
- $ make VERSION=v1.x.x
[homebrew-code]: https://github.com/xvxx/homebrew-code

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

@ -1,14 +1,14 @@
//! args::parse() is used to parse command line arguments into a
//! Config structure.
use crate::{
config::{self, Config},
ui::Mode,
use {
crate::{
config::{self, Config},
encoding::Encoding,
ui::Mode,
},
std::{error::Error, fmt, result::Result},
};
use std::{error::Error, fmt, result::Result};
#[cfg(not(test))]
use atty;
/// The error returned if something goes awry while parsing the
/// command line arguments.
@ -43,7 +43,7 @@ impl Error for ArgError {
pub fn parse<T: AsRef<str>>(args: &[T]) -> Result<Config, ArgError> {
let mut set_nocfg = false;
let mut set_cfg = false;
let mut cfg = config::default();
let mut cfg = Config::default();
// check for config to load / not load first
let mut iter = args.iter();
@ -61,12 +61,8 @@ pub fn parse<T: AsRef<str>>(args: &[T]) -> Result<Config, ArgError> {
}
set_cfg = true;
if let Some(arg) = iter.next() {
cfg = match config::load_file(arg.as_ref()) {
Ok(c) => c,
Err(e) => {
return Err(ArgError::new(format!("error loading config: {}", e)));
}
};
cfg = config::load_file(arg.as_ref())
.map_err(|e| ArgError::new(format!("error loading config: {}", e)))?;
} else {
return Err(ArgError::new("need a config file"));
}
@ -106,6 +102,11 @@ pub fn parse<T: AsRef<str>>(args: &[T]) -> Result<Config, ArgError> {
let mut set_notls = false;
let mut set_tor = false;
let mut set_notor = false;
let mut set_media = false;
let mut set_nomedia = false;
let mut set_autoplay = false;
let mut set_noautoplay = false;
while let Some(arg) = iter.next() {
match arg.as_ref() {
"-v" | "--version" | "-version" => {
@ -126,8 +127,22 @@ pub fn parse<T: AsRef<str>>(args: &[T]) -> Result<Config, ArgError> {
"-p" | "--print" | "-print" => cfg.mode = Mode::Print,
"-l" | "--local" | "-local" => cfg.start = "gopher://127.0.0.1:7070".into(),
"-C" | "--no-config" | "-no-config" => {}
"-c" | "--config" | "-config" => {}
"-c" | "--config" | "-config" => {
iter.next(); // skip arg
}
arg if arg.starts_with("--config=") || arg.starts_with("-config=") => {}
"-t" | "--theme" | "-theme" => {
if let Some(arg) = iter.next() {
cfg.theme = config::load_file(arg.as_ref())
.map_err(|e| ArgError::new(format!("error loading theme: {}", e)))?
.theme;
} else {
return Err(ArgError::new("need a theme file"));
}
}
"--print-theme" => {
cfg.mode = Mode::PrintTheme;
}
"-s" | "--tls" | "-tls" => {
if set_notls {
return Err(ArgError::new("can't set both --tls and --no-tls"));
@ -162,6 +177,60 @@ pub fn parse<T: AsRef<str>>(args: &[T]) -> Result<Config, ArgError> {
set_notor = true;
cfg.tor = false;
}
"-w" | "--wrap" | "-wrap" => {
if let Some(column) = iter.next() {
if let Ok(col) = column.as_ref().parse() {
cfg.wrap = col;
} else {
return Err(ArgError::new("--wrap expects a COLUMN arg"));
}
} else {
return Err(ArgError::new("--wrap expects a COLUMN arg"));
}
}
"-m" | "--media" | "-media" => {
if set_nomedia {
return Err(ArgError::new("can't set both --media and --no-media"));
}
set_media = true;
if let Some(player) = iter.next() {
cfg.media = Some(player.as_ref().to_string());
} else {
return Err(ArgError::new("--media expects a PROGRAM arg"));
}
}
"-M" | "--no-media" | "-no-media" => {
if set_media {
return Err(ArgError::new("can't set both --media and --no-media"));
}
set_nomedia = true;
cfg.media = None;
}
"-a" | "--autoplay" | "-autoplay" => {
if set_nomedia {
return Err(ArgError::new("can't set both --no-media and --autoplay"));
}
if set_noautoplay {
return Err(ArgError::new("can't set both --autoplay and --no-autoplay"));
}
set_autoplay = true;
cfg.autoplay = true;
}
"-A" | "--no-autoplay" | "-no-autoplay" => {
if set_autoplay {
return Err(ArgError::new("can't set both --autoplay and --no-autoplay"));
}
cfg.autoplay = false;
set_noautoplay = true;
}
"-e" | "--encoding" | "-encoding" => {
if let Some(encoding) = iter.next() {
cfg.encoding = Encoding::from_str(encoding.as_ref())
.map_err(|e| ArgError::new(e.to_string()))?;
} else {
return Err(ArgError::new("--encoding expects an ENCODING arg"));
}
}
arg => {
if arg.starts_with('-') {
return Err(ArgError::new(format!("unknown flag: {}", arg)));
@ -181,7 +250,9 @@ pub fn parse<T: AsRef<str>>(args: &[T]) -> Result<Config, ArgError> {
#[cfg(not(test))]
{
if !atty::is(atty::Stream::Stdout) && cfg.mode != Mode::Raw {
if !atty::is(atty::Stream::Stdout)
&& !matches!(cfg.mode, Mode::Raw | Mode::Print | Mode::PrintTheme)
{
cfg.mode = Mode::NoTTY;
}
}

@ -1,113 +0,0 @@
//! Terminal colors.
//! Provides a macro to color text as well as sturcts to get their
//! raw ansi codes.
use std::fmt;
/// Shortcut to produce a String colored with one or more colors.
/// Example:
/// ```
/// let s = color_string!("Red string", Red);
/// let x = color_string!("Hyperlink-ish", Blue, Underline);
macro_rules! color_string {
($s:expr, $( $color:ident ),+) => {{
let mut out = String::from("\x1b[");
$( out.push_str(crate::color::$color::code()); out.push_str(";"); )+
out.push('m');
out.push_str(&$s);
out.push_str(crate::color::Reset.as_ref());
out.replace(";m", "m")
}};
}
/// Shortcut to produce a color's ANSI escape code. Don't forget to Reset!
/// ```
/// let mut o = String::new();
/// o.push_str(color!(Blue));
/// o.push_str(color!(Underline));
/// o.push_str("Hyperlinkish.");
/// o.push_str(color!(Reset));
macro_rules! color {
($color:ident) => {
crate::color::$color.as_ref()
};
}
/// Create a color:: struct that can be used with format!.
/// Example:
/// ```
/// define_color(Red, 91);
/// define_color(Reset, 0);
///
/// println!("{}Error: {}{}", color::Red, msg, color::Reset);
macro_rules! define_color {
($color:ident, $code:literal) => {
#[allow(missing_docs)]
pub struct $color;
impl fmt::Display for $color {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_ref())
}
}
impl $color {
#[allow(missing_docs)]
#[inline]
pub fn code() -> &'static str {
concat!($code)
}
}
impl AsRef<str> for $color {
#[inline]
fn as_ref(&self) -> &'static str {
concat!("\x1b[", $code, "m")
}
}
};
}
define_color!(Reset, 0);
define_color!(Bold, 1);
define_color!(Underline, 4);
define_color!(Grey, 90);
define_color!(Red, 91);
define_color!(Green, 92);
define_color!(Yellow, 93);
define_color!(Blue, 94);
define_color!(Magenta, 95);
define_color!(Cyan, 96);
define_color!(White, 97);
define_color!(Black, 30);
define_color!(DarkRed, 31);
define_color!(DarkGreen, 32);
define_color!(DarkYellow, 33);
define_color!(DarkBlue, 34);
define_color!(DarkMagenta, 35);
define_color!(DarkCyan, 36);
define_color!(DarkWhite, 37);
define_color!(BlackBG, 40);
define_color!(RedBG, 41);
define_color!(GreenBG, 42);
define_color!(YellowBG, 43);
define_color!(BlueBG, 44);
define_color!(MagentaBG, 45);
define_color!(CyanBG, 46);
define_color!(WhiteBG, 47);
#[cfg(test)]
mod tests {
#[test]
fn test_colors() {
assert_eq!(color_string!("Error", Red), "\x1b[91mError\x1b[0m");
assert_eq!(
color_string!("Fancy Pants", Blue, Underline),
"\x1b[94;4mFancy Pants\x1b[0m"
);
assert_eq!(
color_string!("Super-duper-fancy-pants", Magenta, Underline, Bold, BlueBG),
"\x1b[95;4;1;44mSuper-duper-fancy-pants\x1b[0m"
)
}
}

@ -3,19 +3,33 @@
//!
//! An example default config is provided but unused by this module.
use crate::{phetchdir, ui};
use std::{
collections::HashMap,
fs::OpenOptions,
io::{Read, Result},
use {
crate::{
encoding::Encoding,
phetchdir,
theme::{to_color, Theme},
ui,
},
std::{
collections::HashMap,
fs::OpenOptions,
io::{self, Read, Result},
sync::{Arc, RwLock},
},
};
/// Global, shared config.
pub type SharedConfig = Arc<RwLock<Config>>;
/// phetch will look for this file on load.
const CONFIG_FILE: &str = "phetch.conf";
/// Default start page.
const DEFAULT_START: &str = "gopher://phetch/1/home";
/// Default media player.
const DEFAULT_MEDIA_PLAYER: &str = "mpv";
/// Example of what a default phetch.conf would be.
pub const DEFAULT_CONFIG: &str = "## default config file for the phetch gopher client
## gopher://phkt.io/1/phetch
@ -32,8 +46,41 @@ tor no
# Always start in wide mode. (--wide)
wide no
# Program to use to open media files.
media mpv
# Whether to auto play media
autoplay no
# Use emoji indicators for TLS & Tor. (--emoji)
emoji no
# Encoding. Only CP437 and UTF8 are supported.
encoding utf8
# Wrap text at N columns. 0 = off (--wrap)
wrap 0
# How many lines to page up/down by? 0 = full screen
scroll 0
# Path to theme file, if any
# theme ~/.config/phetch/pink.theme
# Inline Theme
ui.cursor white bold
ui.number magenta
ui.menu yellow
ui.text white
item.text cyan
item.menu blue
item.error red
item.search white
item.telnet grey
item.external green
item.download white underline
item.media green underline
item.unsupported whitebg red
";
/// Not all the config options are available in the phetch.conf. We
@ -51,8 +98,20 @@ pub struct Config {
pub wide: bool,
/// Render connection status as emoji
pub emoji: bool,
/// Media player to use.
pub media: Option<String>,
/// Whether to automatically play media
pub autoplay: bool,
/// Default encoding
pub encoding: Encoding,
/// UI mode. Can't be set in conf file.
pub mode: ui::Mode,
/// Column to wrap lines. 0 = off
pub wrap: usize,
/// Scroll by how many lines? 0 = full screen
pub scroll: usize,
/// Color Scheme
pub theme: Theme,
}
impl Default for Config {
@ -63,7 +122,13 @@ impl Default for Config {
tor: false,
wide: false,
emoji: false,
media: Some(DEFAULT_MEDIA_PLAYER.into()),
autoplay: false,
encoding: Encoding::default(),
mode: ui::Mode::default(),
wrap: 0,
scroll: 0,
theme: Theme::default(),
}
}
}
@ -84,7 +149,7 @@ pub fn load() -> Result<Config> {
/// Attempt to load a config from disk.
pub fn load_file(path: &str) -> Result<Config> {
let mut reader = OpenOptions::new().read(true).open(&path)?;
let mut reader = OpenOptions::new().read(true).open(path)?;
let mut file = String::new();
reader.read_to_string(&mut file)?;
parse(&file)
@ -96,8 +161,8 @@ pub fn exists() -> bool {
}
/// Parses a phetch config file into a Config struct.
pub fn parse(text: &str) -> Result<Config> {
let mut cfg = default();
fn parse(text: &str) -> Result<Config> {
let mut cfg = Config::default();
let mut keys: HashMap<&str, bool> = HashMap::new();
for (mut linenum, line) in text.split_terminator('\n').enumerate() {
@ -108,14 +173,17 @@ pub fn parse(text: &str) -> Result<Config> {
}
// skip comments
if let Some('#') = line.chars().nth(0) {
if let Some('#') = line.chars().next() {
continue;
}
// line format: "KEY VALUE"
let parts: Vec<&str> = line.splitn(2, ' ').collect();
if parts.len() != 2 {
return Err(error!("Wrong format for line {}: {:?}", linenum, line));
return Err(error!(
r#"Expected "key value" format on line {}: {:?}"#,
linenum, line
));
}
let (key, val) = (parts[0], parts[1]);
if keys.contains_key(key) {
@ -127,6 +195,74 @@ pub fn parse(text: &str) -> Result<Config> {
"tls" => cfg.tls = to_bool(val)?,
"tor" => cfg.tor = to_bool(val)?,
"wide" => cfg.wide = to_bool(val)?,
"wrap" => {
if let Ok(num) = val.parse() {
cfg.wrap = num;
} else {
return Err(error!(
"`wrap` expects a number value on line {}: {}",
linenum, val
));
}
}
"scroll" => {
if let Ok(num) = val.parse() {
cfg.scroll = num;
} else {
return Err(error!(
"`scroll` expects a number value on line {}: {}",
linenum, val
));
}
}
"media" => {
cfg.media = match val.to_lowercase().as_ref() {
"false" | "none" => None,
_ => Some(val.into()),
}
}
"autoplay" => cfg.autoplay = to_bool(val)?,
"encoding" => {
cfg.encoding = Encoding::from_str(val)
.map_err(|e| error!("{} on line {}: {:?}", e, linenum, line))?;
}
"theme" => {
let homevar = std::env::var("HOME");
if homevar.is_err() && val.contains('~') {
return Err(error!("$HOME not set, can't decode `~`"));
}
cfg.theme = match load_file(&val.replace('~', &homevar.unwrap())) {
Ok(cfg) => cfg.theme,
Err(e) => {
if matches!(e.kind(), io::ErrorKind::NotFound) {
return Err(error!(
"error loading theme: File not found on line {}: {}",
linenum, val
));
} else {
return Err(error!("error loading theme: {:?}", e));
}
}
};
}
// color scheme
"ui.cursor" => cfg.theme.ui_cursor = to_color(val),
"ui.number" => cfg.theme.ui_number = to_color(val),
"ui.menu" => cfg.theme.ui_menu = to_color(val),
"ui.text" => cfg.theme.ui_text = to_color(val),
"item.text" => cfg.theme.item_text = to_color(val),
"item.menu" => cfg.theme.item_menu = to_color(val),
"item.error" => cfg.theme.item_error = to_color(val),
"item.search" => cfg.theme.item_search = to_color(val),
"item.telnet" => cfg.theme.item_telnet = to_color(val),
"item.external" => cfg.theme.item_external = to_color(val),
"item.download" => cfg.theme.item_download = to_color(val),
"item.media" => cfg.theme.item_media = to_color(val),
"item.unsupported" => cfg.theme.item_unsupported = to_color(val),
_ => return Err(error!("Unknown key on line {}: {}", linenum, key)),
}
keys.insert(key, true);
@ -157,6 +293,7 @@ mod tests {
assert_eq!(config.wide, false);
assert_eq!(config.emoji, false);
assert_eq!(config.start, "gopher://phetch/1/home");
assert_eq!(config.media, Some("mpv".to_string()));
}
#[test]
@ -185,6 +322,21 @@ mod tests {
assert_eq!(cfg.wide, true);
}
#[test]
fn test_media() {
let cfg = parse("media FALSE").unwrap();
assert_eq!(cfg.media, None);
let cfg = parse("media None").unwrap();
assert_eq!(cfg.media, None);
let cfg = parse("media /path/to/media-player").unwrap();
assert_eq!(cfg.media, Some("/path/to/media-player".to_string()));
let cfg = parse("media vlc").unwrap();
assert_eq!(cfg.media, Some("vlc".to_string()));
}
#[test]
fn test_no_or_false() {
let cfg = parse("tls false\nwide no\ntor n").unwrap();
@ -199,4 +351,64 @@ mod tests {
let e = res.unwrap_err();
assert_eq!(format!("{}", e), "Duplicate key on line 4: tls");
}
#[test]
fn test_encoding() {
let cfg = parse("tls true\nwide no\nemoji yes").unwrap();
assert_eq!(cfg.tls, true);
assert_eq!(cfg.encoding, Encoding::default());
let cfg = parse("tls true\nencoding utf8\n").unwrap();
assert_eq!(cfg.tls, true);
assert_eq!(cfg.encoding, Encoding::UTF8);
let cfg = parse("tls true\nencoding CP437\n").unwrap();
assert_eq!(cfg.tls, true);
assert_eq!(cfg.encoding, Encoding::CP437);
let res = parse("tls true\nencoding what\n");
assert!(res.is_err());
}
#[test]
fn test_missing_theme() {
if let Err(e) = parse("theme /dont/exists.txt") {
assert_eq!(
format!("{}", e),
"error loading theme: File not found on line 1: /dont/exists.txt"
);
}
}
#[test]
fn test_theme_file() {
use crate::theme::to_words;
let cfg = parse("theme ./tests/pink.theme").unwrap();
assert_eq!(to_words(cfg.theme.item_text), "magenta");
assert_eq!(to_words(cfg.theme.item_menu), "magenta");
assert_eq!(to_words(cfg.theme.item_error), "red");
assert_eq!(to_words(cfg.theme.ui_menu), "cyan");
}
#[test]
fn test_theme() {
use crate::theme::to_words;
let cfg = parse("item.text green\nitem.download red underline").unwrap();
assert_eq!(to_words(cfg.theme.item_text), "green");
assert_eq!(to_words(cfg.theme.item_download), "red underline");
}
#[test]
fn test_theme_bad_values() {
use crate::theme::to_words;
let cfg = parse("item.text skyblue\nitem.download green underline\nitem.error invisible\nitem.search red green blue")
.unwrap();
assert_eq!(to_words(cfg.theme.item_text), "white");
assert_eq!(to_words(cfg.theme.item_download), "green underline");
assert_eq!(to_words(cfg.theme.item_error), "white");
assert_eq!(to_words(cfg.theme.item_search), "red green blue");
}
}

@ -0,0 +1,43 @@
use std::{borrow::Cow, io::Result};
/// Encoding of Gopher response. Only UTF8 and CP437 are supported.
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
pub enum Encoding {
/// Unicode
UTF8,
/// https://en.wikipedia.org/wiki/Code_page_437
CP437,
}
impl Default for Encoding {
fn default() -> Self {
Encoding::UTF8
}
}
impl Encoding {
/// Accepts a string like "UTF8" or "CP437" and returns the
/// appropriate `Encoding`, or an `Err`.
pub fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_ref() {
"utf8" | "utf-8" | "utf 8" => Ok(Encoding::UTF8),
"cp437" | "cp-437" | "cp 437" | "pc8" | "pc-8" | "oem us" | "oem-us" => {
Ok(Encoding::CP437)
}
_ => Err(error!("Expected CP437 or UTF8 encoding")),
}
}
/// Convert `response` into a String according to `encoding`.
pub fn encode<'res>(&self, response: &'res [u8]) -> Cow<'res, str> {
if matches!(self, Encoding::CP437) {
let mut converted = String::with_capacity(response.len());
for b in response {
converted.push_str(cp437::convert_byte(b));
}
Cow::from(converted)
} else {
String::from_utf8_lossy(response)
}
}
}

@ -4,6 +4,7 @@
//! URL parsing that recognizes different protocols like telnet and
//! IPv6 addresses.
use crate::ui::{self, Key};
use std::{
fs,
io::{Read, Result, Write},
@ -12,7 +13,6 @@ use std::{
os::unix::fs::OpenOptionsExt,
time::Duration,
};
use termion::input::TermRead;
#[cfg(feature = "tor")]
use tor_stream::TorStream;
@ -74,7 +74,7 @@ pub struct Url<'a> {
/// Fetches a gopher URL and returns a tuple of:
/// (did tls work?, raw Gopher response)
pub fn fetch_url(url: &str, tls: bool, tor: bool) -> Result<(bool, String)> {
pub fn fetch_url(url: &str, tls: bool, tor: bool) -> Result<(bool, Vec<u8>)> {
let u = parse_url(url);
fetch(u.host, u.port, u.sel, tls, tor)
}
@ -87,13 +87,19 @@ pub fn fetch(
selector: &str,
tls: bool,
tor: bool,
) -> Result<(bool, String)> {
) -> Result<(bool, Vec<u8>)> {
let mut stream = request(host, port, selector, tls, tor)?;
let mut body = Vec::new();
stream.read_to_end(&mut body)?;
let mut out = String::from_utf8_lossy(&body).to_string();
clean_response(&mut out);
Ok((stream.is_tls(), out))
Ok((stream.is_tls(), body))
}
/// Turn a Gopher response from `fetch` into a UTF8 String, cleaning
/// up unprintable characters along the way.
pub fn response_to_string(res: &[u8]) -> String {
let mut s = String::from_utf8_lossy(res).to_string();
clean_response(&mut s);
s
}
/// Removes unprintable characters from Gopher response.
@ -101,26 +107,75 @@ pub fn fetch(
fn clean_response(res: &mut String) {
res.retain(|c| match c {
'\u{007F}' => false,
_ if c >= '\u{0080}' && c <= '\u{009F}' => false,
_ if ('\u{0080}'..='\u{009F}').contains(&c) => false,
_ => true,
})
}
/// Downloads a binary to disk. Allows canceling with Ctrl-c.
/// Downloads menu or text to disk as `filename`.
/// Allows canceling with Ctrl-c, but it's
/// kind of hacky - needs the UI receiver passed in.
/// Returns a tuple of:
/// (path it was saved to, the size in bytes)
pub fn download_url_with_filename(
url: &str,
tls: bool,
tor: bool,
chan: ui::KeyReceiver,
filename: &str,
) -> Result<(String, usize)> {
let u = parse_url(url);
let mut path = std::path::PathBuf::from(".");
path.push(filename);
let mut stream = request(u.host, u.port, u.sel, tls, tor)?;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.append(true)
.open(&path)
.map_err(|e| error!("{}", e))?;
let mut buf = [0; 1024];
let mut bytes = 0;
while let Ok(count) = stream.read(&mut buf) {
if count == 0 {
break;
}
bytes += count;
file.write_all(&buf[..count])?;
if let Ok(chan) = chan.lock() {
if let Ok(Key::Ctrl('c')) = chan.try_recv() {
if path.exists() {
fs::remove_file(path)?;
}
return Err(error!("Download cancelled"));
}
}
}
Ok((filename.to_string(), bytes))
}
/// Downloads a binary to disk. Allows canceling with Ctrl-c, but it's
/// kind of hacky - needs the UI receiver passed in.
/// Returns a tuple of:
/// (path it was saved to, the size in bytes)
pub fn download_url(url: &str, tls: bool, tor: bool) -> Result<(String, usize)> {
pub fn download_url(
url: &str,
tls: bool,
tor: bool,
chan: ui::KeyReceiver,
) -> Result<(String, usize)> {
let u = parse_url(url);
let filename = u
.sel
.split_terminator('/')
.rev()
.nth(0)
.next()
.ok_or_else(|| error!("Bad download filename: {}", u.sel))?;
let mut path = std::path::PathBuf::from(".");
path.push(filename);
let stdin = termion::async_stdin();
let mut keys = stdin.keys();
let mut stream = request(u.host, u.port, u.sel, tls, tor)?;
let mut file = fs::OpenOptions::new()
@ -138,13 +193,16 @@ pub fn download_url(url: &str, tls: bool, tor: bool) -> Result<(String, usize)>
}
bytes += count;
file.write_all(&buf[..count])?;
if let Some(Ok(termion::event::Key::Ctrl('c'))) = keys.next() {
if path.exists() {
fs::remove_file(path)?;
if let Ok(chan) = chan.lock() {
if let Ok(Key::Ctrl('c')) = chan.try_recv() {
if path.exists() {
fs::remove_file(path)?;
}
return Err(error!("Download cancelled"));
}
return Err(error!("Download cancelled"));
}
}
Ok((filename.to_string(), bytes))
}
@ -161,10 +219,10 @@ pub fn request(host: &str, port: &str, selector: &str, tls: bool, tor: bool) ->
{
{
if let Ok(connector) = TlsConnector::new() {
let sock = addr.to_socket_addrs().and_then(|mut socks| {
socks.next().ok_or_else(|| error!("Can't create socket"))
})?;
let stream = TcpStream::connect_timeout(&sock, TCP_TIMEOUT_DURATION)?;
let stream = addr
.to_socket_addrs()?
.find_map(|s| TcpStream::connect_timeout(&s, TCP_TIMEOUT_DURATION).ok())
.ok_or_else(|| error!("Can't create socket"))?;
stream.set_read_timeout(Some(TCP_TIMEOUT_DURATION))?;
if let Ok(mut stream) = connector.connect(host, stream) {
stream.write_all(selector.as_ref())?;
@ -183,15 +241,11 @@ pub fn request(host: &str, port: &str, selector: &str, tls: bool, tor: bool) ->
if tor {
#[cfg(feature = "tor")]
{
let proxy = std::env::var("TOR_PROXY")
let mut stream = std::env::var("TOR_PROXY")
.unwrap_or_else(|_| "127.0.0.1:9050".into())
.to_socket_addrs()?
.nth(0)
.unwrap();
let mut stream = match TorStream::connect_with_address(proxy, addr.as_ref()) {
Ok(s) => s,
Err(e) => return Err(error!("Tor error: {}", e)),
};
.find_map(|s| TorStream::connect_with_address(s, addr.as_ref()).ok())
.ok_or_else(|| error!("Can't create socket"))?;
stream.write_all(selector.as_ref())?;
stream.write_all("\r\n".as_ref())?;
return Ok(Stream {
@ -202,10 +256,10 @@ pub fn request(host: &str, port: &str, selector: &str, tls: bool, tor: bool) ->
}
// no tls or tor, try regular connection
let sock = addr
.to_socket_addrs()
.and_then(|mut socks| socks.next().ok_or_else(|| error!("Can't create socket")))?;
let mut stream = TcpStream::connect_timeout(&sock, TCP_TIMEOUT_DURATION)?;
let mut stream = addr
.to_socket_addrs()?
.find_map(|s| TcpStream::connect_timeout(&s, TCP_TIMEOUT_DURATION).ok())
.ok_or_else(|| error!("Can't create socket"))?;
stream.set_read_timeout(Some(TCP_TIMEOUT_DURATION))?;
stream.write_all(selector.as_ref())?;
stream.write_all("\r\n".as_ref())?;
@ -274,7 +328,7 @@ pub fn parse_url(url: &str) -> Url {
host = &url[..idx];
sel = &url[idx..];
} else {
host = &url;
host = url;
}
// ipv6
@ -301,7 +355,7 @@ pub fn parse_url(url: &str) -> Url {
// ignore type prefix on selector
if typ != Type::Telnet {
let mut chars = sel.chars();
if let (Some('/'), Some(c)) = (chars.nth(0), chars.nth(0)) {
if let (Some('/'), Some(c)) = (chars.next(), chars.next()) {
if let Some(t) = Type::from(c) {
typ = t;
sel = &sel[2..];
@ -334,97 +388,149 @@ mod tests {
"ssh://kiosk@bitreich.org",
"https://github.com/xvxx/phetch",
"telnet://bbs.impakt.net:6502/",
"gopher://some.url/9/file.mp4",
"gopher://some.url/;/file.mp4",
"mtv.com/s/best-of-britney-spears.mp3",
"gopher://microsoft.com:7070/x/developer/sitemap.xml",
"gopher://mtv.com/c/kriss-kross/tour-dates.ical",
"gopher://protonmail.com/M/mymail/inbox.eml",
];
let mut urls = urls.iter();
macro_rules! parse_next_url {
() => {
parse_url(urls.next().unwrap())
};
}
let url = parse_url(urls[0]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Menu);
assert_eq!(url.host, "gopher.club");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "/phlogs/");
let url = parse_url(urls[1]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Menu);
assert_eq!(url.host, "sdf.org");
assert_eq!(url.port, "7777");
assert_eq!(url.sel, "/maps");
let url = parse_url(urls[2]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Menu);
assert_eq!(url.host, "gopher.floodgap.org");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "");
let url = parse_url(urls[3]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Text);
assert_eq!(url.host, "gopher.floodgap.com");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "/gopher/relevance.txt");
let url = parse_url(urls[4]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Search);
assert_eq!(url.host, "gopherpedia.com");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "/lookup?Gopher");
let url = parse_url(urls[5]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Menu);
assert_eq!(url.host, "dead:beef:1234:5678:9012:3456:feed:deed");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "");
let url = parse_url(urls[6]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Menu);
assert_eq!(url.host, "1234:2345:dead:4567:7890:1234:beef:1111");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "/files");
let url = parse_url(urls[7]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Menu);
assert_eq!(url.host, "2001:cdba:0000:0000:0000:0000:3257:9121");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "");
let url = parse_url(urls[8]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Menu);
assert_eq!(url.host, "2001:cdba::3257:9652");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "");
let url = parse_url(urls[9]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Menu);
assert_eq!(url.host, "9999:aaaa::abab:baba:aaaa:9999");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "");
let url = parse_url(urls[10]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Error);
assert_eq!(url.host, "Unclosed ipv6 bracket");
assert_eq!(url.port, "");
assert_eq!(url.sel, "[2001:2099:dead:beef:0000");
let url = parse_url(urls[11]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Menu);
assert_eq!(url.host, "::1");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "");
let url = parse_url(urls[12]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::HTML);
assert_eq!(url.host, "");
assert_eq!(url.port, "");
assert_eq!(url.sel, "ssh://kiosk@bitreich.org");
let url = parse_url(urls[13]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::HTML);
assert_eq!(url.host, "");
assert_eq!(url.port, "");
assert_eq!(url.sel, "https://github.com/xvxx/phetch");
let url = parse_url(urls[14]);
let url = parse_next_url!();
assert_eq!(url.typ, Type::Telnet);
assert_eq!(url.host, "bbs.impakt.net");
assert_eq!(url.port, "6502");
assert_eq!(url.sel, "/");
let url = parse_next_url!();
assert_eq!(url.typ, Type::Binary);
assert_eq!(url.host, "some.url");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "/file.mp4");
let url = parse_next_url!();
assert_eq!(url.typ, Type::Video);
assert_eq!(url.host, "some.url");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "/file.mp4");
let url = parse_next_url!();
assert_eq!(url.typ, Type::Sound);
assert_eq!(url.host, "mtv.com");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "/best-of-britney-spears.mp3");
let url = parse_next_url!();
assert_eq!(url.typ, Type::Xml);
assert_eq!(url.host, "microsoft.com");
assert_eq!(url.port, "7070");
assert_eq!(url.sel, "/developer/sitemap.xml");
let url = parse_next_url!();
assert_eq!(url.typ, Type::Calendar);
assert_eq!(url.host, "mtv.com");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "/kriss-kross/tour-dates.ical");
let url = parse_next_url!();
assert_eq!(url.typ, Type::Mailbox);
assert_eq!(url.host, "protonmail.com");
assert_eq!(url.port, "70");
assert_eq!(url.sel, "/mymail/inbox.eml");
// make sure we got em all
assert_eq!(urls.next(), None);
}
#[test]

@ -2,7 +2,7 @@ use std::fmt;
/// Gopher types are defined according to RFC 1436.
#[allow(missing_docs)]
#[derive(Copy, Clone, PartialEq, Debug)]
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum Type {
Text, // 0 | cyan
Menu, // 1 | blue
@ -21,8 +21,12 @@ pub enum Type {
Image, // I | download
PNG, // p | download
Info, // i | yellow
Sound, // s | download
Sound, // s | green underline
Document, // d | download
Video, // ; | green underline
Xml, // x | cyan
Calendar, // c | download
Mailbox, // M | unsupported
}
impl Type {
@ -33,7 +37,7 @@ impl Type {
/// Text document?
pub fn is_text(self) -> bool {
self == Type::Text
matches!(self, Type::Text | Type::Xml)
}
/// HTML link?
@ -53,31 +57,38 @@ impl Type {
/// Is this something we can download?
pub fn is_download(self) -> bool {
match self {
matches!(
self,
Type::Binhex
| Type::DOSFile
| Type::UUEncoded
| Type::Binary
| Type::GIF
| Type::Image
| Type::PNG
| Type::Sound
| Type::Document => true,
_ => false,
}
| Type::DOSFile
| Type::UUEncoded
| Type::Binary
| Type::GIF
| Type::Image
| Type::PNG
| Type::Sound
| Type::Video
| Type::Calendar
| Type::Document
)
}
/// Check if media to open in player
pub fn is_media(self) -> bool {
matches!(self, Type::Sound | Type::Video)
}
/// Is this a type phetch supports?
pub fn is_supported(self) -> bool {
match self {
Type::CSOEntity | Type::Mirror | Type::Telnet3270 => false,
_ => true,
}
!matches!(
self,
Type::CSOEntity | Type::Mirror | Type::Telnet3270 | Type::Mailbox
)
}
/// Gopher Item Type to RFC char.
pub fn to_char(self) -> Option<char> {
Some(match self {
pub fn to_char(self) -> char {
match self {
Type::Text => '0',
Type::Menu => '1',
Type::CSOEntity => '2',
@ -97,7 +108,11 @@ impl Type {
Type::Info => 'i',
Type::Sound => 's',
Type::Document => 'd',
})
Type::Video => ';',
Type::Calendar => 'c',
Type::Xml => 'x',
Type::Mailbox => 'M',
}
}
/// Create a Gopher Item Type from its RFC char code.
@ -122,6 +137,10 @@ impl Type {
'i' => Type::Info,
's' => Type::Sound,
'd' => Type::Document,
';' => Type::Video,
'c' => Type::Calendar,
'x' => Type::Xml,
'M' => Type::Mailbox,
_ => return None,
})
}
@ -129,10 +148,6 @@ impl Type {
impl fmt::Display for Type {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if let Some(c) = self.to_char() {
write!(f, "{}", c)
} else {
write!(f, "?")
}
write!(f, "{}", self.to_char())
}
}

@ -10,6 +10,7 @@ pub fn lookup(name: &str) -> Option<String> {
"history" => history::as_raw_menu(),
"bookmarks" => bookmarks::as_raw_menu(),
"help/config" => format!("{}{}", HEADER, CONFIG),
"help/themes" => format!("{}{}", HEADER, THEMES),
"help/keys" => format!("{}{}", HEADER, KEYS),
"help/nav" => format!("{}{}", HEADER, NAV),
"help/types" => format!("{}{}", HEADER, TYPES),
@ -84,6 +85,7 @@ i
1bookmarks /help/bookmarks phetch
1history /help/history phetch
1phetch.conf /help/config phetch
1themes /help/themes phetch
i
i ~ * ~
i
@ -121,7 +123,9 @@ is save bookmark
ia show history
i
ir view raw source
id download raw source
iw toggle wide mode
ie toggle encoding
iq quit phetch
ih show help
i
@ -211,14 +215,14 @@ i ** config **
i
iif you create a phetch.conf
ifile in ~/.config/phetch/ it
iwill be automatically loaded
iwill be automatically loaded
iwhen phetch starts. the config
ifile supports most command line
ioptions, for your convenience.
i
ifor example, phetch will always
ilaunch in TLS mode if `tls yes`
iappears in the config file.
iappears in the config file.
i
ihere is an example phetch.conf
iwith all possible keys:
@ -237,6 +241,84 @@ iwide no
i
i# show emoji status indicators
iemoji no
i
i# cp437 or utf8 encoding
iencoding utf8
i
i# wrap text at N cols. 0 = off
iwrap 0
i
i# page up/down by N lines.
i# 0 = full screen
iscroll 0
i
i# path to theme file, if any
itheme ~/.config/phetch/fun.theme
";
const THEMES: &str = "
i ** themes **
i
iyou can change phetch's color
ischeme by supplying your own
itheme file with --theme/-t or
iby setting `theme FILE` in
iyour phetch.conf.
i
iyou can also set colors directly
iin your phetch.conf.
i
iview the current theme with:
i
i$ phetch --print-theme
i
i ** examples **
i
itheme files are plain text files
ithat look like this:
i
iui.cursor white bold
iui.number magenta
iui.menu yellow
iui.text white
iitem.text cyan
iitem.menu blue
iitem.error red
iitem.search white
iitem.telnet grey
iitem.external green
iitem.download white underline
iitem.media green underline
iitem.unsupported whitebg red
i
i ** valid colors **
i
ibold
iunderline
igrey
ired
igreen
iyellow
iblue
imagenta
icyan
iwhite
iblack
idarkred
idarkgreen
idarkyellow
idarkblue
idarkmagenta
idarkcyan
idarkwhite
iblackbg
iredbg
igreenbg
iyellowbg
ibluebg
imagentabg
icyanbg
iwhitebg
";
const TYPES: &str = "
@ -251,7 +333,7 @@ i
8telnet links /help/types phetch
hexternal urls URL:https://en.wikipedia.org/wiki/Phetch phetch
i
iand these download types:
ithese download types:
i
4binhex /help/types phetch
5dosfiles /help/types phetch
@ -259,9 +341,13 @@ i
9binaries /help/types phetch
gGIFs /help/types phetch
Iimages downloads /help/types phetch
ssound files /help/types phetch
ddocuments /help/types phetch
i
iand these media types:
i
ssound files URL:https://freepd.com/music/Wakka%20Wakka.mp3 phetch
;video files URL:https://www.youtube.com/watch?v=oHg5SJYRHA0 phetch
i
iphetch does not support:
i
2CSO Entries /help/types phetch
@ -281,15 +367,15 @@ i ~ * ~
i
i special thanks
i
ikseistrup:
ikseistrup:
i major design, testing,
i documentation help
i
iantirez:
iantirez:
i added gopher to redis
i and opened the door
i
ilartu:
ilartu:
i inspired me to add some
i \x1b[95mcolor\x1b[0m
i

@ -33,13 +33,17 @@
#![allow(clippy::while_let_on_iterator)]
#![allow(clippy::write_with_newline)]
#[macro_use]
extern crate lazy_static;
#[macro_use]
pub mod utils;
#[macro_use]
pub mod color;
pub mod theme;
pub mod args;
pub mod bookmarks;
pub mod config;
mod encoding;
pub mod gopher;
pub mod help;
pub mod history;
@ -74,3 +78,8 @@ pub const TOR_SUPPORT: bool = true;
#[cfg(not(feature = "tor"))]
/// Whether we compiled with Tor support.
pub const TOR_SUPPORT: bool = false;
lazy_static! {
/// Is the NO_COLOR env variable set? https://no-color.org/
pub static ref NO_COLOR: bool = std::env::var("NO_COLOR").is_ok();
}

@ -1,29 +1,34 @@
use phetch::{
args, gopher, menu,
args,
config::{Config, SharedConfig},
gopher, menu, terminal, theme,
ui::{Mode, UI},
};
use std::{env, process};
use std::{
env,
error::Error,
io::{self, stdout, Write},
panic, process, str,
};
fn main() {
process::exit(run())
if let Err(e) = run() {
eprintln!("{}", e);
process::exit(1);
}
}
/// Start the app. Returns UNIX exit code.
fn run() -> i32 {
fn run() -> Result<(), Box<dyn Error>> {
let str_args = env::args().skip(1).collect::<Vec<String>>();
let mut cfg = match args::parse(&str_args) {
Ok(c) => c,
Err(e) => {
eprintln!("{}", e);
return 1;
}
};
let mut cfg = args::parse(&str_args)?;
// check for simple modes
match cfg.mode {
Mode::Raw => return print_raw(&cfg.start, cfg.tls, cfg.tor),
Mode::Version => return print_version(),
Mode::Help => return print_usage(),
Mode::PrintTheme => return print_theme(cfg),
Mode::NoTTY => return print_plain(&cfg.start, cfg.tls, cfg.tor),
Mode::Print => cfg.wide = true,
Mode::Run => {}
@ -33,48 +38,35 @@ fn run() -> i32 {
let start = cfg.start.clone();
let mode = cfg.mode;
let mut ui = UI::new(cfg);
if let Err(e) = ui.open(&start, &start) {
eprintln!("{}", e);
return 1;
}
ui.open(&start, &start)?;
// print rendered version
if mode == Mode::Print {
return match ui.render() {
Ok(screen) => {
println!("{}", screen);
0
}
Err(e) => {
eprintln!("{}", e);
1
}
};
println!("{}", ui.render()?);
return Ok(());
}
// run app
if let Err(e) = ui.run() {
eprintln!("{}", e);
return 1;
}
setup_terminal();
ui.run()?;
cleanup_terminal();
// and scene
0
Ok(())
}
/// --version
fn print_version() -> i32 {
fn print_version() -> Result<(), Box<dyn Error>> {
println!(
"phetch v{version} ({built})",
built = phetch::BUILD_DATE,
version = phetch::VERSION
);
0
Ok(())
}
/// --help
fn print_usage() -> i32 {
print_version();
fn print_usage() -> Result<(), Box<dyn Error>> {
print_version()?;
println!(
"
Usage:
@ -87,14 +79,24 @@ Options:
-s, --tls Try to open Gopher URLs securely w/ TLS
-o, --tor Use local Tor proxy to open all pages
-S, -O Disable TLS or Tor
-w, --wrap COLUMN Wrap long lines in \"text\" views at COLUMN.
-m, --media PROGRAM Use to open media files. Default: mpv
-M, --no-media Just download media files, don't download
-a, --autoplay Autoplay media without prompting.
-A, --no-autoplay Prompt before playing media.
-r, --raw Print raw Gopher response only
-p, --print Print rendered Gopher response only
-l, --local Connect to 127.0.0.1:7070
-e, --encoding Render text documents in CP437 or UTF8.
-c, --config FILE Use instead of ~/.config/phetch/phetch.conf
-C, --no-config Don't use any config file
-C, --no-config Don't use any config file
-t, --theme FILE Use FILE for color theme or print current theme.
--print-theme Print current theme.
-h, --help Show this screen
-v, --version Show phetch version
@ -102,48 +104,76 @@ Command line options always override options set in phetch.conf.
Once you've launched phetch, use `ctrl-h` to view the on-line help."
);
0
Ok(())
}
/// Print just the raw Gopher response.
fn print_raw(url: &str, tls: bool, tor: bool) -> i32 {
match gopher::fetch_url(url, tls, tor) {
Ok((_, response)) => {
println!("{}", response);
0
}
Err(e) => {
eprintln!("{}", e);
1
}
}
fn print_raw(url: &str, tls: bool, tor: bool) -> Result<(), Box<dyn Error>> {
let (_, out) = gopher::fetch_url(url, tls, tor)?;
println!("{}", gopher::response_to_string(&out));
Ok(())
}
/// Print a colorless, plain version of the response for a non-tty
/// (like a pipe).
fn print_plain(url: &str, tls: bool, tor: bool) -> i32 {
fn print_plain(url: &str, tls: bool, tor: bool) -> Result<(), Box<dyn Error>> {
let mut out = String::new();
let typ = gopher::type_for_url(url);
match gopher::fetch_url(url, tls, tor) {
Ok((_, response)) => match typ {
gopher::Type::Menu => {
let menu = menu::parse(url, response);
for line in menu.lines {
out.push_str(line.text(&menu.raw));
out.push('\n');
}
let (_, response) = gopher::fetch_url(url, tls, tor)?;
let response = gopher::response_to_string(&response);
match typ {
gopher::Type::Menu => {
let menu = menu::parse(url, response, SharedConfig::default());
for line in menu.lines() {
out.push_str(line.text());
out.push('\n');
}
gopher::Type::Text => println!("{}", response.trim_end_matches(".\r\n")),
_ => {
eprintln!("can't print gopher type: {:?}", typ);
return 1;
}
},
Err(e) => {
eprintln!("{}", e);
return 1;
}
}
gopher::Type::Text => println!("{}", response.trim_end_matches(".\r\n")),
_ => {
return Err(Box::new(io::Error::new(
io::ErrorKind::Other,
format!("can't print gopher type: {:?}", typ),
)));
}
};
print!("{}", out);
0
Ok(())
}
/// Print current theme as plaintext
fn print_theme(cfg: Config) -> Result<(), Box<dyn Error>> {
println!("{}", cfg.theme);
Ok(())
}
/// Put the terminal into raw mode, enter the alternate screen, and
/// setup the panic handler.
fn setup_terminal() {
let old_handler = panic::take_hook();
panic::set_hook(Box::new(move |info| {
cleanup_terminal();
old_handler(info);
}));
terminal::enable_raw_mode().expect("Fatal Error entering Raw Mode.");
write!(stdout(), "{}", terminal::ToAlternateScreen)
.expect("Fatal Error entering Alternate Mode.");
}
/// Leave raw mode. Need to always do this, even on panic.
fn cleanup_terminal() {
let mut stdout = stdout();
write!(
stdout,
"{}{}{}{}{}",
theme::color::Reset,
terminal::ClearAll,
terminal::Goto(1, 1),
terminal::ShowCursor,
terminal::ToMainScreen
)
.expect("Fatal Error cleaning up terminal.");
stdout.flush().expect("Fatal Error cleaning up terminal.");
terminal::disable_raw_mode().expect("Fatal Error leaving Raw Mode.");
}

@ -7,10 +7,10 @@
//! it returns an Action to the UI representing its intent.
use crate::{
config::Config,
config::SharedConfig as Config,
gopher::{self, Type},
terminal,
ui::{self, Action, Key, View, MAX_COLS, SCROLL_LINES},
ui::{self, Action, Key, View, MAX_COLS},
};
use std::fmt;
@ -23,8 +23,9 @@ use std::fmt;
pub struct Menu {
/// Gopher URL
pub url: String,
/// Lines in the menu. Not all are links.
pub lines: Vec<Line>,
/// Lines in the menu. Not all are links. Use the `lines()` iter
/// or `line(N)` or `link(N)` to access one.
spans: Vec<LineSpan>,
/// Indexes of links in the `lines` vector. Pauper's pointers.
pub links: Vec<usize>,
/// Currently selected link. Index of the `links` vec.
@ -37,8 +38,8 @@ pub struct Menu {
pub input: String,
/// UI mode. Interactive (Run), Printing, Raw mode...
pub mode: ui::Mode,
/// Scrolling offset, in rows.
pub scroll: usize,
/// Scrolling offset, in rows. 0 = full screen
pub offset: usize,
/// Incremental search mode?
pub searching: bool,
/// Was this menu retrieved via TLS?
@ -49,55 +50,47 @@ pub struct Menu {
pub size: (usize, usize),
/// Wide mode?
wide: bool,
/// Scroll by how many lines?
scroll: usize,
/// Global config
config: Config,
}
/// The Line represents a single line in a Gopher menu.
/// It must exist in the context of a Menu struct - its `link`
/// field is its index in the Menu's `links` Vec, and
/// start/end/text_end point to locations in Menu's `raw` Gopher
/// response.
pub struct Line {
/// Gopher Item Type.
pub typ: Type,
/// Where this line starts in its Menu's `raw` Gopher response.
start: usize,
/// Where this line ends in Menu.raw.
end: usize,
/// Where the text/label of this line ends. Might be the same as
/// `end`, or might be earlier.
text_end: usize,
/// Index of this link in the Menu::links vector, if it's a
/// `gopher::Type.is_link()`
pub link: usize,
/// Represents a line in a Gopher menu. Provides the actual text of
/// the line, vs LineSpan which is just location data.
pub struct Line<'line, 'txt: 'line> {
span: &'line LineSpan,
text: &'txt str,
}
impl Line {
/// Returns the text field of this line, given a raw Gopher response.
/// The same Line must always be used with the same Gopher response.
pub fn text<'a>(&self, raw: &'a str) -> &'a str {
impl<'line, 'txt> Line<'line, 'txt> {
fn new(span: &'line LineSpan, text: &'txt str) -> Line<'line, 'txt> {
Line { span, text }
}
/// Visible line as text. What appeared in the raw Gopher
/// response.
pub fn text(&self) -> &str {
if self.start < self.text_end {
&raw[self.start + 1..self.text_end]
&self.text[self.start + 1..self.text_end]
} else {
""
}
}
/// Get the length of this line's text field.
pub fn text_len(&self) -> usize {
if self.text_end > self.start {
self.text_end - self.start
} else {
0
}
/// Truncated version of the line, according to visible characters
/// and MAX_COLS.
pub fn text_truncated(&self) -> String {
self.text().chars().take(self.truncated_len).collect()
}
/// Get the URL for this line, if it's a link.
pub fn url(&self, raw: &str) -> String {
/// URL for this line, if it's a link.
pub fn url(&self) -> String {
if !self.typ.is_link() || self.text_end >= self.end {
return String::from("");
}
let line = &raw[self.text_end..self.end].trim_end_matches('\r');
let line = &self.text[self.text_end..self.end].trim_end_matches('\r');
let mut sel = "(null)";
let mut host = "localhost";
let mut port = "70";
@ -131,6 +124,80 @@ impl Line {
}
}
/// Line wraps LineSpan.
impl<'line, 'txt: 'line> std::ops::Deref for Line<'line, 'txt> {
type Target = LineSpan;
fn deref(&self) -> &Self::Target {
self.span
}
}
/// The LineSpan represents a single line's location in a Gopher menu.
/// It only exists in the context of a Menu struct - its `link`
/// field is its index in the Menu's `links` Vec, and
/// start/end/text_end point to locations in Menu's `raw` Gopher
/// response.
/// You won't really interact with this directly, instead call
/// `menu.lines()` get an iter over `Line` or `menu.line(idx)` to get
/// a single Line.
pub struct LineSpan {
/// Gopher Item Type.
pub typ: Type,
/// Where this line starts in its Menu's `raw` Gopher response.
start: usize,
/// Where this line ends in Menu.raw.
end: usize,
/// Where the text/label of this line ends. Might be the same as
/// `end`, or might be earlier.
text_end: usize,
/// Length of visible text, ignoring ANSI escape codes (colors).
visible_len: usize,
/// How many chars() to grab from text() if we want to only show
/// `MAX_COLS` visible chars on screen, aka ignore ANSI escape
/// codes and colors.
truncated_len: usize,
/// Index of this link in the Menu::links vector, if it's a
/// `gopher::Type.is_link()`
pub link: usize,
}
impl LineSpan {
/// Get the length of this line's text field.
pub fn text_len(&self) -> usize {
self.visible_len
}
}
/// Iterator over (dynamically created) Line structs.
pub struct LinesIter<'menu> {
spans: &'menu [LineSpan],
text: &'menu str,
curr: usize,
}
impl<'menu> LinesIter<'menu> {
fn new(spans: &'menu [LineSpan], text: &'menu str) -> LinesIter<'menu> {
LinesIter {
spans,
text,
curr: 0,
}
}
}
impl<'menu> Iterator for LinesIter<'menu> {
type Item = Line<'menu, 'menu>;
fn next(&mut self) -> Option<Self::Item> {
if self.curr >= self.spans.len() {
None
} else {
let line_with = Line::new(&self.spans[self.curr], self.text);
self.curr += 1;
Some(line_with)
}
}
}
/// Direction of a given link relative to the visible screen.
#[derive(PartialEq)]
enum LinkPos {
@ -186,16 +253,37 @@ impl View for Menu {
impl Menu {
/// Create a representation of a Gopher Menu from a raw Gopher
/// response and a few options.
pub fn from(url: &str, response: String, config: &Config, tls: bool) -> Menu {
pub fn from(url: &str, response: String, config: Config, tls: bool) -> Menu {
Menu {
tls,
tor: config.tor,
wide: config.wide,
mode: config.mode,
..parse(url, response)
tor: config.read().unwrap().tor,
wide: config.read().unwrap().wide,
scroll: config.read().unwrap().scroll,
mode: config.read().unwrap().mode,
..parse(url, response, config.clone())
}
}
/// Lines in this menu. Main iterator for getting Line with text.
pub fn lines(&self) -> LinesIter {
LinesIter::new(&self.spans, &self.raw)
}
/// Get a single Line in this menu by index.
pub fn line(&self, idx: usize) -> Option<Line> {
if idx >= self.spans.len() {
None
} else {
Some(Line::new(&self.spans[idx], &self.raw))
}
}
/// Find a link by its link index.
pub fn link(&self, idx: usize) -> Option<Line> {
let line = self.links.get(idx)?;
self.line(*line)
}
fn cols(&self) -> usize {
self.size.0
}
@ -204,6 +292,14 @@ impl Menu {
self.size.1
}
fn scroll_by(&self) -> usize {
if self.scroll == 0 {
self.rows() - 1
} else {
self.scroll
}
}
/// Calculated size of left margin.
fn indent(&self) -> usize {
if self.wide {
@ -227,12 +323,6 @@ impl Menu {
}
}
/// Find a link by its link index.
fn link(&self, i: usize) -> Option<&Line> {
let line = self.links.get(i)?;
self.lines.get(*line)
}
/// Is the given link visible on screen?
fn is_visible(&self, link: usize) -> bool {
self.link_visibility(link) == Some(LinkPos::Visible)
@ -241,9 +331,9 @@ impl Menu {
/// Where is the given link relative to the screen?
fn link_visibility(&self, i: usize) -> Option<LinkPos> {
let &pos = self.links.get(i)?;
Some(if pos < self.scroll {
Some(if pos < self.offset {
LinkPos::Above
} else if pos >= self.scroll + self.rows() - 1 {
} else if pos >= self.offset + self.rows() - 1 {
LinkPos::Below
} else {
LinkPos::Visible
@ -257,10 +347,10 @@ impl Menu {
}
let &pos = self.links.get(link)?;
let x = self.indent() + 1;
let y = if self.scroll > pos {
let y = if self.offset > pos {
pos + 1
} else {
pos + 1 - self.scroll
pos + 1 - self.offset
};
Some((x as u16, y as u16))
@ -273,64 +363,61 @@ impl Menu {
// (status bar is always last line)
self.rows() - 1
} else {
self.lines.len()
self.spans.len()
};
let iter = self.lines.iter().skip(self.scroll).take(limit);
let iter = self.lines().skip(self.offset).take(limit);
let indent = self.indent();
let left_margin = " ".repeat(indent);
for line in iter {
out.push_str(&left_margin);
let config = self.config.read().unwrap();
if line.typ == Type::Info {
out.push_str(" ");
} else {
if line.link == self.link && self.show_cursor() {
out.push_str(color!(Bold));
out.push_str(&config.theme.ui_cursor);
out.push('*');
out.push_str(color!(Reset));
out.push_str(reset_color!());
} else {
out.push(' ');
}
out.push(' ');
out.push_str(color!(Magenta));
out.push_str(&config.theme.ui_number);
if line.link < 9 {
out.push(' ');
}
let num = (line.link + 1).to_string();
out.push_str(&num);
out.push_str(". ");
out.push_str(color!(Reset));
out.push_str(reset_color!());
}
// truncate long lines, instead of wrapping
let text = if line.text_len() > MAX_COLS {
&line.text(&self.raw)[..MAX_COLS]
} else {
&line.text(&self.raw)
};
let text = line.text_truncated();
// color the line
if line.typ.is_download() {
out.push_str(color!(Underline));
out.push_str(color!(White));
if line.typ.is_media() {
out.push_str(&config.theme.item_media);
} else if line.typ.is_download() {
out.push_str(&config.theme.item_download);
} else if !line.typ.is_supported() {
out.push_str(color!(WhiteBG));
out.push_str(color!(Red));
out.push_str(&self.config.read().unwrap().theme.item_unsupported);
} else {
out.push_str(&match line.typ {
Type::Text => color!(Cyan),
Type::Menu => color!(Blue),
Type::Info => color!(Yellow),
Type::HTML => color!(Green),
Type::Error => color!(Red),
Type::Telnet => color!(Grey),
Type::Search => color!(White),
_ => color!(Red),
out.push_str(match line.typ {
Type::Text => &config.theme.item_text,
Type::Menu => &config.theme.item_menu,
Type::Info => &config.theme.ui_menu,
Type::HTML => &config.theme.item_external,
Type::Error => &config.theme.item_error,
Type::Telnet => &config.theme.item_telnet,
Type::Search => &config.theme.item_search,
_ => &config.theme.item_error,
});
}
out.push_str(&text);
out.push_str(color!(Reset));
out.push_str(reset_color!());
// clear rest of line
out.push_str(terminal::ClearUntilNewline.as_ref());
@ -375,8 +462,9 @@ impl Menu {
}
let (x, y) = self.screen_coords(self.link)?;
Some(format!(
"{}\x1b[97;1m*\x1b[0m{}",
"{}{}*\x1b[0m{}",
terminal::Goto(x, y),
self.config.read().unwrap().theme.ui_cursor,
terminal::HideCursor
))
}
@ -399,11 +487,11 @@ impl Menu {
}
}
/// Scroll down by SCROLL_LINES, if possible.
/// Scroll down by a page, if possible.
fn action_page_down(&mut self) -> Action {
// If there are fewer menu items than screen lines, just
// select the final link and do nothing else.
if self.lines.len() < self.rows() {
if self.spans.len() < self.rows() {
if !self.links.is_empty() {
self.link = self.links.len() - 1;
return Action::Redraw;
@ -413,8 +501,8 @@ impl Menu {
// If we've already scrolled too far, select the final link
// and do nothing.
if self.scroll >= self.final_scroll() {
self.scroll = self.final_scroll();
if self.offset >= self.final_offset() {
self.offset = self.final_offset();
if !self.links.is_empty() {
self.link = self.links.len() - 1;
}
@ -422,11 +510,11 @@ impl Menu {
}
// Scroll...
self.scroll += SCROLL_LINES;
self.offset += self.scroll_by();
// ...but don't go past the final line.
if self.scroll > self.final_scroll() {
self.scroll = self.final_scroll();
if self.offset > self.final_offset() {
self.offset = self.final_offset();
}
// If the selected link isn't visible...
@ -436,9 +524,9 @@ impl Menu {
.links
.iter()
.skip(self.link + 1)
.find(|&&i| i >= self.scroll)
.find(|&&i| i >= self.offset)
{
if let Some(next_link_line) = self.lines.get(next_link_pos) {
if let Some(next_link_line) = self.line(next_link_pos) {
self.link = next_link_line.link;
}
}
@ -448,11 +536,11 @@ impl Menu {
}
fn action_page_up(&mut self) -> Action {
if self.scroll > 0 {
if self.scroll > SCROLL_LINES {
self.scroll -= SCROLL_LINES;
if self.offset > 0 {
if self.offset > self.scroll_by() {
self.offset -= self.scroll_by();
} else {
self.scroll = 0;
self.offset = 0;
}
if self.link == 0 {
return Action::Redraw;
@ -460,7 +548,7 @@ impl Menu {
if let Some(dir) = self.link_visibility(self.link) {
match dir {
LinkPos::Below => {
let scroll = self.scroll;
let scroll = self.offset;
if let Some(&pos) = self
.links
.iter()
@ -468,7 +556,7 @@ impl Menu {
.rev()
.find(|&&i| i < (self.rows() + scroll - 1))
{
self.link = self.lines.get(pos).unwrap().link;
self.link = self.line(pos).unwrap().link;
}
}
LinkPos::Above => {}
@ -487,8 +575,8 @@ impl Menu {
fn action_up(&mut self) -> Action {
// no links, just scroll up
if self.link == 0 {
return if self.scroll > 0 {
self.scroll -= 1;
return if self.offset > 0 {
self.offset -= 1;
Action::Redraw
} else if !self.links.is_empty() {
self.link = self.links.len() - 1;
@ -513,8 +601,8 @@ impl Menu {
match dir {
LinkPos::Above => {
// scroll up by 1
if self.scroll > 0 {
self.scroll -= 1;
if self.offset > 0 {
self.offset -= 1;
}
// select it if it's visible now
if self.is_visible(new_link) {
@ -524,7 +612,7 @@ impl Menu {
LinkPos::Below => {
// jump to link....
if let Some(&pos) = self.links.get(new_link) {
self.scroll = pos;
self.offset = pos;
self.link = new_link;
}
}
@ -534,8 +622,8 @@ impl Menu {
self.link = new_link;
// scroll if we are within 5 lines of the top
if let Some(&pos) = self.links.get(self.link) {
if self.scroll > 0 && pos < self.scroll + 5 {
self.scroll -= 1;
if self.offset > 0 && pos < self.offset + 5 {
self.offset -= 1;
} else {
// otherwise redraw just the cursor
return self.reset_cursor(old_link);
@ -549,11 +637,11 @@ impl Menu {
}
}
/// Final `self.scroll` value.
fn final_scroll(&self) -> usize {
/// Final `self.offset` value.
fn final_offset(&self) -> usize {
let padding = (self.rows() as f64 * 0.9) as usize;
if self.lines.len() > padding {
self.lines.len() - padding
if self.spans.len() > padding {
self.spans.len() - padding
} else {
0
}
@ -576,8 +664,8 @@ impl Menu {
{
let pattern = pattern.to_ascii_lowercase();
for &pos in it {
let line = self.lines.get(pos)?;
if line.text(&self.raw).to_ascii_lowercase().contains(&pattern) {
let line = self.line(pos)?;
if line.text().to_ascii_lowercase().contains(&pattern) {
return Some(line.link);
}
}
@ -590,13 +678,13 @@ impl Menu {
// no links or final link selected already
if self.links.is_empty() || new_link >= self.links.len() {
// if there are more rows, scroll down
if self.lines.len() >= self.rows() && self.scroll < self.final_scroll() {
self.scroll += 1;
if self.spans.len() >= self.rows() && self.offset < self.final_offset() {
self.offset += 1;
return Action::Redraw;
} else if !self.links.is_empty() {
// wrap around
self.link = 0;
self.scroll = 0;
self.offset = 0;
return Action::Redraw;
}
}
@ -616,13 +704,13 @@ impl Menu {
LinkPos::Above => {
// jump to link....
if let Some(&pos) = self.links.get(new_link) {
self.scroll = pos;
self.offset = pos;
self.link = new_link;
}
}
LinkPos::Below => {
// scroll down by 1
self.scroll += 1;
self.offset += 1;
// select it if it's visible now
if self.is_visible(new_link) {
self.link = new_link;
@ -635,10 +723,10 @@ impl Menu {
self.link = new_link;
// scroll if we are within 5 lines of the end
if self.lines.len() >= self.rows() // dont scroll if content too small
&& pos >= self.scroll + self.rows() - 6
if self.spans.len() >= self.rows() // dont scroll if content too small
&& pos >= self.offset + self.rows() - 6
{
self.scroll += 1;
self.offset += 1;
} else {
// otherwise try to just re-draw the cursor
return self.reset_cursor(old_link);
@ -668,9 +756,9 @@ impl Menu {
}
} else {
if pos > 5 {
self.scroll = pos - 5;
self.offset = pos - 5;
} else {
self.scroll = 0;
self.offset = 0;
}
if !self.input.is_empty() {
Action::List(vec![self.redraw_input(), Action::Redraw])
@ -694,12 +782,12 @@ impl Menu {
if !self.is_visible(link) {
if let Some(&pos) = self.links.get(link) {
if pos > 5 {
self.scroll = pos - 5;
self.offset = pos - 5;
} else {
self.scroll = 0;
self.offset = 0;
}
if self.scroll > self.final_scroll() {
self.scroll = self.final_scroll();
if self.offset > self.final_offset() {
self.offset = self.final_offset();
}
return Action::Redraw;
}
@ -718,11 +806,11 @@ impl Menu {
self.input.clear();
if let Some(line) = self.link(self.link) {
let url = line.url(&self.raw);
let url = line.url();
let typ = gopher::type_for_url(&url);
match typ {
Type::Search => {
let prompt = format!("{}> ", line.text(&self.raw));
let prompt = format!("{}> ", line.text());
Action::Prompt(
prompt.clone(),
Box::new(move |query| {
@ -733,9 +821,9 @@ impl Menu {
}),
)
}
Type::Error => Action::Error(line.text(&self.raw).to_string()),
Type::Error => Action::Error(line.text().to_string()),
t if !t.is_supported() => Action::Error(format!("{:?} not supported", t)),
_ => Action::Open(line.text(&self.raw).to_string(), url),
_ => Action::Open(line.text().to_string(), url),
}
} else {
Action::None
@ -782,12 +870,12 @@ impl Menu {
Key::PageUp | Key::Ctrl('-') | Key::Char('-') => self.action_page_up(),
Key::PageDown | Key::Ctrl(' ') | Key::Char(' ') => self.action_page_down(),
Key::Home => {
self.scroll = 0;
self.offset = 0;
self.link = 0;
Action::Redraw
}
Key::End => {
self.scroll = self.final_scroll();
self.offset = self.final_offset();
if !self.links.is_empty() {
self.link = self.links.len() - 1;
}
@ -819,7 +907,7 @@ impl Menu {
}
}
Key::Char(c) => {
if !c.is_digit(10) {
if !c.is_ascii_digit() {
return Action::Keypress(key);
}
@ -848,8 +936,8 @@ impl Menu {
}
/// Parse gopher response into a Menu object.
pub fn parse(url: &str, raw: String) -> Menu {
let mut lines = vec![];
pub fn parse(url: &str, raw: String, config: Config) -> Menu {
let mut spans = vec![];
let mut links = vec![];
let mut longest = 0;
let mut start = 0;
@ -860,20 +948,20 @@ pub fn parse(url: &str, raw: String) -> Menu {
break;
}
if line == "" {
if line.is_empty() {
start += 1;
continue;
}
if let Some(mut line) = parse_line(start, &raw) {
if line.text_len() > longest {
longest = line.text_len();
if let Some(mut span) = parse_line(start, &raw) {
if span.text_len() > longest {
longest = span.text_len();
}
if line.typ.is_link() {
line.link = links.len();
links.push(lines.len());
if span.typ.is_link() {
span.link = links.len();
links.push(spans.len());
}
lines.push(line);
spans.push(span);
}
start += line.len() + 1;
@ -881,24 +969,26 @@ pub fn parse(url: &str, raw: String) -> Menu {
Menu {
url: url.into(),
lines,
spans,
links,
longest,
raw,
input: String::new(),
link: 0,
mode: Default::default(),
scroll: 0,
offset: 0,
searching: false,
size: (0, 0),
tls: false,
tor: false,
wide: false,
scroll: 0,
config,
}
}
/// Parses a single line from a Gopher menu into a `Line` struct.
pub fn parse_line(start: usize, raw: &str) -> Option<Line> {
/// Parses a single line from a Gopher menu into a `LineSpan` struct.
pub fn parse_line(start: usize, raw: &str) -> Option<LineSpan> {
if raw.is_empty() || start >= raw.len() {
return None;
}
@ -913,12 +1003,48 @@ pub fn parse_line(start: usize, raw: &str) -> Option<Line> {
} else {
end
};
let typ = Type::from(line.chars().nth(0)?)?;
let typ = Type::from(line.chars().next()?).unwrap_or(Type::Binary);
Some(Line {
let mut truncated_len = if text_end - start > MAX_COLS {
MAX_COLS + 1
} else {
text_end - start
};
let mut visible_len = truncated_len;
// if this line contains colors, calculate the visible length and
// where to truncate when abiding by `MAX_COLS`
if raw[start..text_end].contains("\x1b[") {
let mut is_color = false;
let mut iter = raw[start..text_end].char_indices();
visible_len = 0;
while let Some((i, c)) = iter.next() {
if is_color {
if c == 'm' {
is_color = false;
}
} else if c == '\x1b' {
if let Some((_, '[')) = iter.next() {
is_color = true;
}
} else if visible_len < MAX_COLS {
truncated_len = i;
visible_len += 1;
} else {
truncated_len = i;
visible_len = MAX_COLS + 1;
break;
}
}
}
Some(LineSpan {
start,
end,
text_end,
truncated_len,
visible_len,
typ,
link: 0,
})
@ -929,8 +1055,8 @@ mod tests {
use super::*;
macro_rules! parse {
($s:literal) => {
parse("test", $s.to_string());
($s:expr) => {
parse("test", $s.to_string(), Config::default())
};
}
@ -950,29 +1076,41 @@ i----------- spacer localhost 70
i---------------------------------------------------------
"
);
assert_eq!(menu.lines.len(), 10);
assert_eq!(menu.spans.len(), 10);
assert_eq!(menu.links.len(), 5);
assert_eq!(
menu.lines[1].url(&menu.raw),
menu.lines().nth(1).unwrap().url(),
"gopher://gopher.club/1/phlogs/"
);
assert_eq!(menu.lines[2].url(&menu.raw), "gopher://sdf.org/1/maps/");
assert_eq!(
menu.lines[3].url(&menu.raw),
menu.lines().nth(2).unwrap().url(),
"gopher://sdf.org/1/maps/"
);
assert_eq!(
menu.lines().nth(3).unwrap().url(),
"gopher://earth.rice.edu/1Geosphere"
);
assert_eq!(menu.lines[4].text(&menu.raw), "wacky links");
assert_eq!(menu.lines[5].text(&menu.raw), "-----------");
assert_eq!(menu.lines[6].url(&menu.raw), "telnet://bbs.impakt.net:6502");
assert_eq!(menu.lines[7].url(&menu.raw), "https://github.com/my/code");
assert_eq!(menu.lines[8].text(&menu.raw), "-----------");
assert_eq!(menu.lines().nth(4).unwrap().text(), "wacky links");
assert_eq!(menu.lines().nth(5).unwrap().text(), "-----------");
assert_eq!(
menu.lines().nth(6).unwrap().url(),
"telnet://bbs.impakt.net:6502"
);
assert_eq!(
menu.lines().nth(7).unwrap().url(),
"https://github.com/my/code"
);
assert_eq!(menu.lines().nth(8).unwrap().text(), "-----------");
}
#[test]
fn test_no_path() {
let menu = parse!("1Circumlunar Space circumlunar.space 70");
assert_eq!(menu.links.len(), 1);
assert_eq!(menu.lines[0].url(&menu.raw), "gopher://circumlunar.space");
assert_eq!(
menu.lines().next().unwrap().url(),
"gopher://circumlunar.space"
);
}
#[test]
@ -1004,26 +1142,17 @@ i Err bitreich.org 70
menu.term_size(80, 40);
assert_eq!(menu.links.len(), 9);
assert_eq!(menu.link(0).unwrap().url(), "gopher://bitreich.org/1/lawn");
assert_eq!(
menu.link(0).unwrap().url(&menu.raw),
"gopher://bitreich.org/1/lawn"
);
assert_eq!(
menu.link(1).unwrap().url(&menu.raw),
menu.link(1).unwrap().url(),
"gopher://bitreich.org/1/tutorials"
);
assert_eq!(
menu.link(2).unwrap().url(&menu.raw),
"gopher://bitreich.org/1/onion"
);
assert_eq!(
menu.link(3).unwrap().url(&menu.raw),
"gopher://bitreich.org/1/kiosk"
);
assert_eq!(menu.link(2).unwrap().url(), "gopher://bitreich.org/1/onion");
assert_eq!(menu.link(3).unwrap().url(), "gopher://bitreich.org/1/kiosk");
assert_eq!(menu.link, 0);
let ssh = menu.link(4).unwrap();
assert_eq!(ssh.url(&menu.raw), "ssh://kiosk@bitreich.org");
assert_eq!(ssh.url(), "ssh://kiosk@bitreich.org");
assert_eq!(ssh.typ, Type::HTML);
menu.action_down();
@ -1042,9 +1171,36 @@ i Err bitreich.org 70
assert_eq!(menu.link, 7);
assert_eq!(menu.link(menu.link).unwrap().link, 7);
assert_eq!(menu.scroll, 0);
assert_eq!(menu.offset, 0);
menu.action_page_up();
assert_eq!(menu.link, 0);
assert_eq!(menu.link(menu.link).unwrap().link, 0);
}
#[test]
fn test_color_lines() {
let long_color_line = "ihi there. \x1b[1mthis\x1b[0m is a preeeeeety long line with \x1b[93mcolors \x1b[92mthat make it \x1b[91mseem longer than it is\x1b[0m /kiosk bitreich.org 70";
let menu = parse!(long_color_line);
let line = menu.lines().next().unwrap();
assert_eq!(long_color_line.chars().count(), 139);
assert_eq!(line.visible_len, MAX_COLS + 1);
assert_eq!(line.truncated_len, 100);
assert_eq!(
line.text_truncated(),
"hi there. \x1b[1mthis\x1b[0m is a preeeeeety long line with \x1b[93mcolors \x1b[92mthat make it \x1b[91mseem longer".to_string()
);
let long_reg_line = "1This is a regular line that is long but also has links and stuff. You are missing a gopher client? Use our kiosk mode. Thanks for coming. Hope you enjoy the fish, it's freshly grown in our lab! /kiosk bitreich.org 70";
let menu = parse!(long_reg_line);
let line = menu.lines().next().unwrap();
assert_eq!(long_color_line.chars().count(), 139);
assert_eq!(line.visible_len, MAX_COLS + 1);
assert_eq!(line.truncated_len, MAX_COLS + 1);
assert_eq!(
line.text_truncated(),
"This is a regular line that is long but also has links and stuff. You are miss"
.to_string()
);
}
}

@ -42,11 +42,11 @@ pub fn append(filename: &str, label: &str, url: &str) -> Result<()> {
path().and_then(|dotdir| {
let path = dotdir.join(filename);
if let Ok(mut file) = OpenOptions::new().append(true).create(true).open(path) {
let u = gopher::parse_url(&url);
let u = gopher::parse_url(url);
write!(
file,
"{}{}\t{}\t{}\t{}\r\n",
u.typ.to_char().unwrap_or('i'),
u.typ.to_char(),
label,
u.sel,
u.host,
@ -69,14 +69,14 @@ pub fn prepend(filename: &str, label: &str, url: &str) -> Result<()> {
.create(true)
.open(path)
{
let url = gopher::parse_url(&url);
let url = gopher::parse_url(url);
let mut buf = vec![];
file.read_to_end(&mut buf)?;
file.seek(std::io::SeekFrom::Start(0))?;
write!(
file,
"{}{}\t{}\t{}\t{}\r\n",
url.typ.to_char().unwrap_or('i'),
url.typ.to_char(),
label,
url.sel,
url.host,

@ -1,10 +1,10 @@
//! The terminal module mostly provides terminal escape sequences for
//! things like clearing the screen or going into alternate mode.
//!
//! It wraps termion for now, but we may move away from termion in the
//! future and this will help.
//! things like clearing the screen or going into alternate mode, as
//! well as raw mode borrowed from crossterm.
use termion;
use lazy_static::lazy_static;
use libc::{cfmakeraw, tcgetattr, tcsetattr, termios as Termios, STDIN_FILENO, TCSANOW};
use std::{io, sync::Mutex};
pub use termion::cursor::Goto;
pub use termion::cursor::Hide as HideCursor;
@ -14,5 +14,77 @@ pub use termion::screen::ToAlternateScreen;
pub use termion::screen::ToMainScreen;
pub use termion::clear::AfterCursor as ClearAfterCursor;
pub use termion::clear::All as ClearAll;
pub use termion::clear::CurrentLine as ClearCurrentLine;
pub use termion::clear::UntilNewline as ClearUntilNewline;
type Result<T> = std::result::Result<T, io::Error>;
lazy_static! {
// Some(Termios) -> we're in the raw mode and this is the previous mode
// None -> we're not in the raw mode
static ref TERMINAL_MODE_PRIOR_RAW_MODE: Mutex<Option<Termios>> = Mutex::new(None);
}
/// Are we in raw mode?
pub fn is_raw_mode_enabled() -> bool {
TERMINAL_MODE_PRIOR_RAW_MODE.lock().unwrap().is_some()
}
/// Go into "raw" mode, courtesy of crossterm.
pub fn enable_raw_mode() -> Result<()> {
let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock().unwrap();
if original_mode.is_some() {
return Ok(());
}
let mut ios = get_terminal_attr()?;
let original_mode_ios = ios;
raw_terminal_attr(&mut ios);
set_terminal_attr(&ios)?;
// Keep it last - set the original mode only if we were able to switch to the raw mode
*original_mode = Some(original_mode_ios);
Ok(())
}
/// Back it up.
pub fn disable_raw_mode() -> Result<()> {
let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock().unwrap();
if let Some(original_mode_ios) = original_mode.as_ref() {
set_terminal_attr(original_mode_ios)?;
// Keep it last - remove the original mode only if we were able to switch back
*original_mode = None;
}
Ok(())
}
// Transform the given mode into an raw mode (non-canonical) mode.
fn raw_terminal_attr(termios: &mut Termios) {
unsafe { cfmakeraw(termios) }
}
fn get_terminal_attr() -> Result<Termios> {
unsafe {
let mut termios = std::mem::zeroed();
wrap_with_result(tcgetattr(STDIN_FILENO, &mut termios))?;
Ok(termios)
}
}
fn set_terminal_attr(termios: &Termios) -> Result<bool> {
wrap_with_result(unsafe { tcsetattr(STDIN_FILENO, TCSANOW, termios) })
}
fn wrap_with_result(result: i32) -> Result<bool> {
if result == -1 {
Err(io::Error::last_os_error())
} else {
Ok(true)
}
}

@ -3,21 +3,26 @@
//! to the main UI to perform.
use crate::{
config::Config,
config::SharedConfig as Config,
encoding::Encoding,
terminal,
ui::{self, Action, Key, View, MAX_COLS, SCROLL_LINES},
ui::{self, Action, Key, View, MAX_COLS},
};
use std::fmt;
use std::{borrow::Cow, fmt, str};
/// The Text View holds the raw Gopher response as well as information
/// about which lines should currently be displayed on screen.
pub struct Text {
/// Ref to our global config
config: Config,
/// Gopher URL
url: String,
/// Gopher response
raw_response: String,
raw_response: Vec<u8>,
/// Encoded response
encoded_response: String,
/// Current scroll offset, in rows
scroll: usize,
offset: usize,
/// Number of lines
lines: usize,
/// Size of longest line
@ -30,8 +35,12 @@ pub struct Text {
pub tor: bool,
/// UI mode. Interactive (Run), Printing, Raw mode...
mode: ui::Mode,
/// Text Encoding of Response
encoding: Encoding,
/// Currently in wide mode?
pub wide: bool,
/// How many lines to scroll by. 0 = full screen
scroll: usize,
}
impl fmt::Display for Text {
@ -54,7 +63,7 @@ impl View for Text {
}
fn raw(&self) -> &str {
self.raw_response.as_ref()
str::from_utf8(&self.raw_response).unwrap_or_default()
}
fn term_size(&mut self, cols: usize, rows: usize) {
@ -69,38 +78,43 @@ impl View for Text {
self.wide
}
fn encoding(&self) -> Encoding {
self.encoding
}
fn respond(&mut self, c: Key) -> Action {
match c {
Key::Home => {
self.scroll = 0;
self.offset = 0;
Action::Redraw
}
Key::End => {
self.scroll = self.final_scroll();
self.offset = self.final_scroll();
Action::Redraw
}
Key::Ctrl('e') | Key::Char('e') => self.toggle_encoding(),
Key::Down | Key::Ctrl('n') | Key::Char('n') | Key::Ctrl('j') | Key::Char('j') => {
if self.scroll < self.final_scroll() {
self.scroll += 1;
if self.offset < self.final_scroll() {
self.offset += 1;
Action::Redraw
} else {
Action::None
}
}
Key::Up | Key::Ctrl('p') | Key::Char('p') | Key::Ctrl('k') | Key::Char('k') => {
if self.scroll > 0 {
self.scroll -= 1;
if self.offset > 0 {
self.offset -= 1;
Action::Redraw
} else {
Action::None
}
}
Key::PageUp | Key::Char('-') => {
if self.scroll > 0 {
if self.scroll >= SCROLL_LINES {
self.scroll -= SCROLL_LINES;
if self.offset > 0 {
if self.offset >= self.scroll_by() {
self.offset -= self.scroll_by();
} else {
self.scroll = 0;
self.offset = 0;
}
Action::Redraw
} else {
@ -108,9 +122,9 @@ impl View for Text {
}
}
Key::PageDown | Key::Char(' ') => {
self.scroll += SCROLL_LINES;
if self.scroll > self.final_scroll() {
self.scroll = self.final_scroll();
self.offset += self.scroll_by();
if self.offset > self.final_scroll() {
self.offset = self.final_scroll();
}
Action::Redraw
}
@ -119,29 +133,19 @@ impl View for Text {
}
fn render(&mut self) -> String {
let (cols, rows) = self.size;
let (_cols, rows) = self.size;
let mut out = String::new();
let longest = if self.longest > MAX_COLS {
MAX_COLS
} else {
self.longest
};
let indent = if cols >= longest && cols - longest <= 6 {
String::from("")
} else if cols >= longest {
" ".repeat((cols - longest) / 2)
} else {
String::from("")
};
let wrap = self.config.read().unwrap().wrap;
let indent = self.indent_str(wrap);
let limit = if self.mode == ui::Mode::Run {
rows - 1
} else {
self.lines
};
let iter = self
.raw_response
.split_terminator('\n')
.skip(self.scroll)
let iter = wrap_text(&self.encoded_response, wrap)
.into_iter()
.skip(self.offset)
.take(limit);
for line in iter {
@ -170,30 +174,55 @@ impl View for Text {
impl Text {
/// Create a Text View from a raw Gopher response and a few options.
pub fn from(url: &str, response: String, config: &Config, tls: bool) -> Text {
let mut lines = 0;
let mut longest = 0;
for line in response.split_terminator('\n') {
lines += 1;
let count = line.chars().count();
if count > longest {
longest = count;
}
}
pub fn from(url: &str, response: Vec<u8>, config: Config, tls: bool) -> Text {
let mode = config.read().unwrap().mode;
let tor = config.read().unwrap().tor;
let encoding = config.read().unwrap().encoding;
let wide = config.read().unwrap().wide;
let scroll = config.read().unwrap().scroll;
Text {
let mut new = Text {
config,
url: url.into(),
encoded_response: String::new(),
raw_response: response,
scroll: 0,
lines,
longest,
offset: 0,
lines: 0,
longest: 0,
size: (0, 0),
mode: config.mode,
mode,
tls,
tor: config.tor,
wide: config.wide,
tor,
encoding,
wide,
scroll,
};
new.encode_response();
new
}
/// Toggle between our two encodings.
fn toggle_encoding(&mut self) -> Action {
if matches!(self.encoding, Encoding::UTF8) {
self.encoding = Encoding::CP437;
} else {
self.encoding = Encoding::UTF8;
}
self.config.write().unwrap().encoding = self.encoding;
self.encode_response();
Action::Redraw
}
/// Convert the response to a Rust String and cache metadata like
/// the number of lines.
fn encode_response(&mut self) {
self.encoded_response = self.encoding.encode(&self.raw_response).into();
let wrapped = wrap_text(
self.encoded_response.as_ref(),
self.config.read().unwrap().wrap,
);
self.lines = wrapped.len();
self.longest = wrapped.iter().map(|line| line.len()).max().unwrap_or(0) as usize;
}
/// Final `self.scroll` value.
@ -205,4 +234,165 @@ impl Text {
0
}
}
/// How many lines to scroll by when paging up or down.
fn scroll_by(&self) -> usize {
if self.scroll == 0 {
self.size.1 - 1
} else {
self.scroll
}
}
/// Determine the longest line, considering any line wrapping and
/// `MAX_COL`.
fn longest_line_with_wrap(&self, wrap: usize) -> usize {
let longest = if self.longest > MAX_COLS {
MAX_COLS
} else {
self.longest
};
if wrap > 0 && longest > wrap {
wrap
} else {
longest
}
}
/// Produce the string to use for indentation, or the left margin,
/// for a given text document.
fn indent_str(&self, wrap: usize) -> Cow<str> {
let (cols, _) = self.size;
let longest = self.longest_line_with_wrap(wrap);
if cols >= longest && cols - longest <= 6 {
Cow::from("")
} else if cols >= longest {
Cow::from(" ".repeat((cols - longest) / 2))
} else {
Cow::from("")
}
}
}
/// Splits a chunk of text into a vector of strings with at most
/// `wrap` characters each. Tries to be smart and wrap at punctuation,
/// otherwise just wraps at `wrap`.
fn wrap_text(lines: &str, wrap: usize) -> Vec<&str> {
if wrap == 0 {
return lines.split('\n').collect();
}
let mut out = vec![];
for mut line in lines.lines() {
let mut len = line.chars().count();
if len > wrap {
while len > wrap {
let (end, _) = line.char_indices().take(wrap + 1).last().unwrap();
if !matches!(&line[end - 1..end], " " | "-" | "," | "." | ":") {
if let Some(&(end, _)) = line
.char_indices()
.take(wrap + 1)
.collect::<Vec<_>>()
.iter()
.rev()
.skip(1)
.find(|(_, c)| matches!(c, ' ' | '-' | ',' | '.' | ':'))
{
out.push(&line[..=end]);
if end + 1 < line.len() {
line = &line[end + 1..];
len -= end;
} else {
len = 0;
break;
}
continue;
}
}
out.push(&line[..end]);
line = &line[end..];
len -= wrap;
}
if len > 0 {
out.push(line);
}
} else {
out.push(line);
}
}
out
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_cp437() {
let body = include_bytes!("../tests/CP437.txt");
let mut text = Text::from("", body.to_vec(), Config::default(), false);
text.mode = ui::Mode::Print;
let res = text.render();
assert!(!res.contains("╟"));
assert!(!res.contains("≈"));
assert!(!res.contains("Ω"));
assert!(!res.contains("Θ"));
text.toggle_encoding();
let res = text.render();
assert!(res.contains("╟"));
assert!(res.contains("≈"));
assert!(res.contains("Ω"));
assert!(res.contains("Θ"));
}
#[test]
fn test_wrapping() {
let text = "regular line
really really really really really really really really really really long line
super duper extra scooper hoopa loopa double doopa maxi paxi giga baxi very very long line
Qua nova re oblata omnis administratio belliconsistit militesque aversi a proelio ad studium audiendi et cognoscendi feruntur ubi hostes ad legatosexercitumque pervenerunt universi se ad pedes proiciunt orant ut adventus Caesaris expectetur captamsuam urbem videre...
really really really really really really really really really kinda-but-not-really long line
another regular line
";
let lines = wrap_text(text, 70);
assert_eq!("regular line", lines[0]);
assert_eq!(
"really really really really really really really really really really",
lines[1].trim()
);
assert_eq!("long line", lines[2].trim());
assert_eq!(
"super duper extra scooper hoopa loopa double doopa maxi paxi giga",
lines[3].trim()
);
assert_eq!("baxi very very long line", lines[4].trim());
assert_eq!(
"Qua nova re oblata omnis administratio belliconsistit militesque",
lines[5].trim()
);
assert_eq!(
"aversi a proelio ad studium audiendi et cognoscendi feruntur ubi",
lines[6].trim()
);
assert_eq!(
"hostes ad legatosexercitumque pervenerunt universi se ad pedes",
lines[7].trim()
);
assert_eq!(
"really really really really really really really really really kinda-",
lines[10].trim()
);
assert_eq!("but-not-really long line", lines[11].trim());
assert_eq!(13, lines.len());
}
}

@ -0,0 +1,233 @@
//! Terminal color scheme.
//! Provides the Theme struct and functions/macros for making use of it.
use std::fmt;
/// Provides a shortcut to the Reset color code.
pub mod color {
use std::fmt;
/// Can be used with fmt calls to reset to terminal defaults.
pub struct Reset;
impl fmt::Display for Reset {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "\x1b[0m")
}
}
}
/// Use with push_str() or something.
macro_rules! reset_color {
() => {
"\x1b[0m"
};
}
/// Color scheme for UI and menu items.
#[derive(Debug)]
pub struct Theme {
// UI Colors
/// The * cursor that appears next to the selected menu item.
pub ui_cursor: String,
/// The Number that appears to the left of a menu item.
pub ui_number: String,
/// The text in a menu.
pub ui_menu: String,
/// The color of the text content in a document.
pub ui_text: String,
// Menu Item Colors
/// Text document.
pub item_text: String,
/// Another menu.
pub item_menu: String,
/// Something went wrong.
pub item_error: String,
/// Gopher search prompt
pub item_search: String,
/// Telnet item. MUDs and stuff.
pub item_telnet: String,
/// External link. HTTP, usually.
pub item_external: String,
/// Binary file that can be downloaded to disk.
pub item_download: String,
/// Media that can be opened, like an image or mp3.
pub item_media: String,
/// An unknown or unsupported Gopher type.
pub item_unsupported: String,
}
impl Default for Theme {
fn default() -> Theme {
Theme {
ui_cursor: to_color("white bold"),
ui_number: to_color("magenta"),
ui_menu: to_color("yellow"),
ui_text: to_color("white"),
item_text: to_color("cyan"),
item_menu: to_color("blue"),
item_error: to_color("red"),
item_search: to_color("white"),
item_telnet: to_color("grey"),
item_external: to_color("green"),
item_download: to_color("white underline"),
item_media: to_color("green underline"),
item_unsupported: to_color("whitebg red"),
}
}
}
impl fmt::Display for Theme {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"# phetch theme
ui.cursor {ui_cursor}
ui.number {ui_number}
ui.menu {ui_menu}
ui.text {ui_text}
item.text {item_text}
item.menu {item_menu}
item.error {item_error}
item.search {item_search}
item.telnet {item_telnet}
item.external {item_external}
item.download {item_download}
item.media {item_media}
item.unsupported {item_unsupported}",
ui_cursor = to_words(&self.ui_cursor),
ui_number = to_words(&self.ui_number),
ui_menu = to_words(&self.ui_menu),
ui_text = to_words(&self.ui_text),
item_text = to_words(&self.item_text),
item_menu = to_words(&self.item_menu),
item_error = to_words(&self.item_error),
item_search = to_words(&self.item_search),
item_telnet = to_words(&self.item_telnet),
item_external = to_words(&self.item_external),
item_download = to_words(&self.item_download),
item_media = to_words(&self.item_media),
item_unsupported = to_words(&self.item_unsupported),
)
}
}
/// Convert a string like "blue underline" or "red" into a color code.
pub fn to_color<S: AsRef<str>>(line: S) -> String {
if *crate::NO_COLOR {
return "".into();
}
let parts = line.as_ref().split(' ').collect::<Vec<_>>();
if parts.is_empty() {
return "".into();
}
let mut out = String::from("\x1b[");
let len = parts.len();
for (i, part) in parts.iter().enumerate() {
out.push_str(&color_code(part).to_string());
if i < len - 1 {
out.push(';');
}
}
out.push('m');
out
}
/// Convert color code like "\x1b[91m" into something like "red"
pub fn to_words<S: AsRef<str>>(code: S) -> String {
code.as_ref()
.replace("\x1b[", "")
.replace('m', "")
.split(';')
.map(color_word)
.collect::<Vec<_>>()
.join(" ")
}
fn color_code(color: &str) -> usize {
match color {
"bold" => 1,
"underline" => 4,
"grey" => 90,
"red" => 91,
"green" => 92,
"yellow" => 93,
"blue" => 94,
"magenta" => 95,
"cyan" => 96,
"white" => 97,
"black" => 30,
"darkred" => 31,
"darkgreen" => 32,
"darkyellow" => 33,
"darkblue" => 34,
"darkmagenta" => 35,
"darkcyan" => 36,
"darkwhite" => 37,
"blackbg" => 40,
"redbg" => 41,
"greenbg" => 42,
"yellowbg" => 43,
"bluebg" => 44,
"magentabg" => 45,
"cyanbg" => 46,
"whitebg" => 47,
_ => 0,
}
}
fn color_word(code: &str) -> &'static str {
match code {
"1" => "bold",
"4" => "underline",
"90" => "grey",
"91" => "red",
"92" => "green",
"93" => "yellow",
"94" => "blue",
"95" => "magenta",
"96" => "cyan",
"97" => "white",
"30" => "black",
"31" => "darkred",
"32" => "darkgreen",
"33" => "darkyellow",
"34" => "darkblue",
"35" => "darkmagenta",
"36" => "darkcyan",
"37" => "darkwhite",
"40" => "blackbg",
"41" => "redbg",
"42" => "greenbg",
"43" => "yellowbg",
"44" => "bluebg",
"45" => "magentabg",
"46" => "cyanbg",
"47" => "whitebg",
_ => "white",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_color_scheme() {
let mut theme = Theme::default();
theme.ui_cursor = to_color("bold").into();
theme.ui_menu = to_color("red").into();
theme.item_menu = to_color("blue underline").into();
assert_eq!("\u{1b}[1m", theme.ui_cursor);
assert_eq!("\u{1b}[91m", theme.ui_menu);
assert_eq!("\u{1b}[94;4m", theme.item_menu);
}
}

@ -17,34 +17,33 @@ mod view;
pub use self::{action::Action, mode::Mode, view::View};
use crate::{
bookmarks, color,
config::Config,
bookmarks,
config::{Config, SharedConfig},
encoding::Encoding,
gopher::{self, Type},
help, history,
menu::Menu,
terminal,
text::Text,
utils, BUG_URL,
theme, utils, BUG_URL,
};
use std::{
cell::RefCell,
io::{stdin, stdout, Result, Stdout, Write},
io::{stdin, stdout, Result, Write},
process::{self, Stdio},
sync::mpsc,
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex, RwLock,
},
thread,
time::Duration,
};
use termion::{
input::TermRead,
raw::{IntoRawMode, RawTerminal},
terminal_size,
};
use termion::{input::TermRead, terminal_size};
/// Alias for a termion Key event.
pub type Key = termion::event::Key;
/// How many lines to jump by when using page up/down.
pub const SCROLL_LINES: usize = 15;
/// Channel to receive Key events on.
pub type KeyReceiver = Arc<Mutex<Receiver<Key>>>;
/// How big the longest line can be, for the purposes of calculating
/// margin sizes. We often draw longer lines than this and allow
@ -55,10 +54,30 @@ pub const MAX_COLS: usize = 77;
/// (network, parsing gopher response, etc) and just show an error
/// message in the status bar, but if we can't write to STDOUT or
/// control the screen, we need to just crash.
const ERR_RAW_MODE: &str = "Fatal Error using Raw Mode.";
const ERR_SCREEN: &str = "Fatal Error using Alternate Screen.";
const ERR_STDOUT: &str = "Fatal Error writing to STDOUT.";
lazy_static! {
/// Channel to send SIGWINCH (resize) events on, once received.
static ref RESIZE_SENDER: Arc<Mutex<Option<Sender<Key>>>> = Arc::new(Mutex::new(None));
}
/// Raw resize handler that is called when SIGWINCH is received.
fn resize_handler(_: i32) {
if let Some(sender) = &*RESIZE_SENDER.lock().unwrap() {
sender.send(Key::F(5)).unwrap();
}
}
/// No-op INT handler that is called when SIGINT (ctrl-c) is
/// received in child processes (like `telnet`).
fn sigint_handler(_: i32) {}
/// Handler for when the application is resumed after ctrl-z.
fn sigcont_handler(_: i32) {
terminal::enable_raw_mode().expect("Fatal Error entering Raw Mode.");
}
/// UI is mainly concerned with drawing to the screen, managing the
/// active views, and responding to user input.
pub struct UI {
@ -75,8 +94,9 @@ pub struct UI {
/// Status message to display on screen, if any
status: String,
/// User config. Command line options + phetch.conf
config: Config,
out: RefCell<RawTerminal<Stdout>>,
config: SharedConfig,
/// Channel where UI events are sent.
keys: KeyReceiver,
}
impl UI {
@ -87,56 +107,33 @@ impl UI {
size = (cols as usize, rows as usize);
};
// Store raw terminal but don't enable it yet or switch the
// screen. We don't want to stare at a fully blank screen
// while waiting for a slow page to load.
let out = stdout().into_raw_mode().expect(ERR_RAW_MODE);
out.suspend_raw_mode().expect(ERR_RAW_MODE);
UI {
views: vec![],
focused: 0,
dirty: true,
running: true,
size,
config,
config: Arc::new(RwLock::new(config)),
status: String::new(),
out: RefCell::new(out),
keys: Self::spawn_keyboard_listener(),
}
}
/// Prepare stdout for writing. Should be used in interactive
/// mode, eg inside run()
pub fn startup(&mut self) {
let mut out = self.out.borrow_mut();
out.activate_raw_mode().expect(ERR_RAW_MODE);
write!(out, "{}", terminal::ToAlternateScreen).expect(ERR_SCREEN);
}
/// Clean up after ourselves. Should only be used after running in
/// interactive mode.
pub fn shutdown(&mut self) {
let mut out = self.out.borrow_mut();
write!(out, "{}", terminal::ToMainScreen).expect(ERR_SCREEN);
}
/// Main loop.
pub fn run(&mut self) -> Result<()> {
self.startup();
while self.running {
self.draw()?;
self.update();
}
self.shutdown();
Ok(())
}
/// Print the current view to the screen in rendered form.
pub fn draw(&mut self) -> Result<()> {
let status = self.render_status();
let mut out = stdout();
if self.dirty {
let screen = self.render()?;
let mut out = self.out.borrow_mut();
write!(
out,
"{}{}{}{}",
@ -148,7 +145,6 @@ impl UI {
out.flush()?;
self.dirty = false;
} else {
let mut out = self.out.borrow_mut();
out.write_all(status.as_ref())?;
out.flush()?;
}
@ -162,16 +158,38 @@ impl UI {
self.status.clear();
}
if let Err(e) = self.process_action(action) {
self.set_status(&format!("{}{}{}", color::Red, e, terminal::HideCursor));
self.set_status(&format!(
"{}{}{}",
&self.config.read().unwrap().theme.item_error,
e,
terminal::HideCursor
));
}
}
/// Reload the currently focused view while preserving history.
pub fn reload(&mut self, title: &str, url: &str) -> Result<()> {
let mut rest = if self.views.len() > self.focused + 1 {
self.views.drain(self.focused..).collect()
} else {
vec![self.views.remove(self.views.len() - 1)]
};
if self.focused > 0 {
self.focused -= 1;
}
self.open(title, url)?;
if rest.len() > 1 {
rest.remove(0); // drop the view we're reloading
self.views.append(&mut rest);
}
Ok(())
}
/// Open a URL - Gopher, internal, telnet, or something else.
pub fn open(&mut self, title: &str, url: &str) -> Result<()> {
// no open loops
if let Some(view) = self.views.get(self.focused) {
if view.url() == url {
return Ok(());
return self.reload(title, url);
}
}
@ -192,6 +210,18 @@ impl UI {
// binary downloads
let typ = gopher::type_for_url(url);
if typ.is_media() && self.config.read().unwrap().media.is_some() {
self.dirty = true;
return if self.config.read().unwrap().autoplay
|| self.confirm(&format!("Open in media player? {}", url))
{
utils::open_media(self.config.read().unwrap().media.as_ref().unwrap(), url)
} else {
Ok(())
};
}
if typ.is_download() {
self.dirty = true;
return if self.confirm(&format!("Download {}?", url)) {
@ -201,21 +231,48 @@ impl UI {
};
}
self.load(title, url).and_then(|view| {
self.load(title, url).map(|view| {
self.add_view(view);
Ok(())
})
}
/// Used to download content of the current view with a provided filename
fn download_file_with_filename(&mut self, url: &str, filename: String) -> Result<()> {
let url = url.to_string();
let (tls, tor) = (
self.config.read().unwrap().tls,
self.config.read().unwrap().tor,
);
let chan = self.keys.clone();
self.spinner(&format!("Downloading {}", url), move || {
gopher::download_url_with_filename(&url, tls, tor, chan, &filename)
})
.and_then(|res| res)
.map(|(path, bytes)| {
self.set_status(
format!(
"Download complete! {} saved to {}",
utils::human_bytes(bytes),
path
)
.as_ref(),
);
})
}
/// Download a binary file. Used by `open()` internally.
fn download(&mut self, url: &str) -> Result<()> {
let url = url.to_string();
let (tls, tor) = (self.config.tls, self.config.tor);
let (tls, tor) = (
self.config.read().unwrap().tls,
self.config.read().unwrap().tor,
);
let chan = self.keys.clone();
self.spinner(&format!("Downloading {}", url), move || {
gopher::download_url(&url, tls, tor)
gopher::download_url(&url, tls, tor, chan)
})
.and_then(|res| res)
.and_then(|(path, bytes)| {
.map(|(path, bytes)| {
self.set_status(
format!(
"Download complete! {} saved to {}",
@ -224,7 +281,6 @@ impl UI {
)
.as_ref(),
);
Ok(())
})
}
@ -240,17 +296,25 @@ impl UI {
thread::spawn(move || history::save(&hname, &hurl));
// request thread
let thread_url = url.to_string();
let (tls, tor) = (self.config.tls, self.config.tor);
let (tls, tor) = (
self.config.read().unwrap().tls,
self.config.read().unwrap().tor,
);
// don't spin on first ever request
let (tls, res) = if self.views.is_empty() {
gopher::fetch_url(&thread_url, tls, tor)?
} else {
self.spinner("", move || gopher::fetch_url(&thread_url, tls, tor))??
};
let typ = gopher::type_for_url(&url);
let typ = gopher::type_for_url(url);
match typ {
Type::Menu | Type::Search => Ok(Box::new(Menu::from(url, res, &self.config, tls))),
Type::Text | Type::HTML => Ok(Box::new(Text::from(url, res, &self.config, tls))),
Type::Menu | Type::Search => Ok(Box::new(Menu::from(
url,
gopher::response_to_string(&res),
self.config.clone(),
tls,
))),
Type::Text | Type::HTML => Ok(Box::new(Text::from(url, res, self.config.clone(), tls))),
_ => Err(error!("Unsupported Gopher Response: {:?}", typ)),
}
}
@ -258,10 +322,15 @@ impl UI {
/// Get Menu for on-line help, home page, etc, ex: gopher://phetch/1/help/types
fn load_internal(&mut self, url: &str) -> Result<Box<dyn View>> {
if let Some(source) = help::lookup(
&url.trim_start_matches("gopher://phetch/")
url.trim_start_matches("gopher://phetch/")
.trim_start_matches("1/"),
) {
Ok(Box::new(Menu::from(url, source, &self.config, false)))
Ok(Box::new(Menu::from(
url,
source,
self.config.clone(),
false,
)))
} else {
Err(error!("phetch URL not found: {}", url))
}
@ -291,7 +360,7 @@ impl UI {
) -> Result<T> {
let req = thread::spawn(work);
let (tx, rx) = mpsc::channel();
let (tx, rx) = channel();
let label = label.to_string();
let rows = self.rows() as u16;
thread::spawn(move || loop {
@ -306,7 +375,7 @@ impl UI {
label,
".".repeat(i),
terminal::ClearUntilNewline,
color::Reset,
theme::color::Reset,
terminal::ShowCursor,
);
stdout().flush().expect(ERR_STDOUT);
@ -351,22 +420,41 @@ impl UI {
/// Render the connection status (TLS or Tor).
fn render_conn_status(&self) -> Option<String> {
let view = self.views.get(self.focused)?;
let mut status = vec![];
if matches!(view.encoding(), Encoding::CP437) {
status.push("CP439");
}
if view.is_tls() {
let status = color_string!("TLS", Black, GreenBG);
return Some(format!(
"{}{}",
terminal::Goto(self.cols() - 3, self.rows()),
if self.config.emoji { "🔐" } else { &status },
));
if self.config.read().unwrap().emoji {
status.push("🔐");
} else {
status.push("TLS");
}
} else if view.is_tor() {
let status = color_string!("TOR", Bold, White, MagentaBG);
return Some(format!(
if self.config.read().unwrap().emoji {
status.push("🧅");
} else {
status.push("TOR");
}
}
if status.is_empty() {
None
} else {
let len = status.iter().fold(0, |a, s| a + s.len());
let len = len + status.len();
Some(format!(
"{}{}",
terminal::Goto(self.cols() - 3, self.rows()),
if self.config.emoji { "🧅" } else { &status },
));
terminal::Goto(self.cols() - len as u16, self.rows()),
status
.iter()
.map(|s| theme::to_color("bold white") + s + reset_color!())
.collect::<Vec<_>>()
.join(" "),
))
}
None
}
/// Render the status line.
@ -378,7 +466,7 @@ impl UI {
terminal::ClearCurrentLine,
self.status,
self.render_conn_status().unwrap_or_else(|| "".into()),
color::Reset,
theme::color::Reset,
)
}
@ -398,11 +486,11 @@ impl UI {
fn confirm(&self, question: &str) -> bool {
let rows = self.rows();
let mut out = self.out.borrow_mut();
let mut out = stdout();
write!(
out,
"{}{}{}{} [Y/n]: {}",
color::Reset,
theme::color::Reset,
terminal::Goto(1, rows),
terminal::ClearCurrentLine,
question,
@ -411,12 +499,8 @@ impl UI {
.expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
if let Some(Ok(key)) = stdin().keys().next() {
match key {
Key::Char('\n') => true,
Key::Char('y') | Key::Char('Y') => true,
_ => false,
}
if let Ok(key) = self.keys.lock().unwrap().recv() {
matches!(key, Key::Char('\n') | Key::Char('y') | Key::Char('Y'))
} else {
false
}
@ -427,11 +511,11 @@ impl UI {
let rows = self.rows();
let mut input = value.to_string();
let mut out = self.out.borrow_mut();
let mut out = stdout();
write!(
out,
"{}{}{}{}{}{}",
color::Reset,
theme::color::Reset,
terminal::Goto(1, rows),
terminal::ClearCurrentLine,
prompt,
@ -441,39 +525,36 @@ impl UI {
.expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
for k in stdin().keys() {
if let Ok(key) = k {
match key {
Key::Char('\n') => {
write!(
out,
"{}{}",
terminal::ClearCurrentLine,
terminal::HideCursor
)
.expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
return Some(input);
}
Key::Char(c) => input.push(c),
Key::Esc | Key::Ctrl('c') => {
write!(
out,
"{}{}",
terminal::ClearCurrentLine,
terminal::HideCursor
)
.expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
return None;
}
Key::Backspace | Key::Delete => {
input.pop();
}
_ => {}
let keys = self.keys.lock().unwrap();
for key in keys.iter() {
match key {
Key::Char('\n') => {
write!(
out,
"{}{}",
terminal::ClearCurrentLine,
terminal::HideCursor
)
.expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
return Some(input);
}
} else {
break;
Key::Char(c) => input.push(c),
Key::Esc | Key::Ctrl('c') => {
write!(
out,
"{}{}",
terminal::ClearCurrentLine,
terminal::HideCursor
)
.expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
return None;
}
Key::Backspace | Key::Delete => {
input.pop();
}
_ => {}
}
write!(
@ -498,8 +579,8 @@ impl UI {
/// Opens an interactive telnet session.
fn telnet(&mut self, url: &str) -> Result<()> {
let gopher::Url { host, port, .. } = gopher::parse_url(url);
let out = self.out.borrow_mut();
out.suspend_raw_mode().expect(ERR_RAW_MODE);
terminal::disable_raw_mode()?;
let mut cmd = process::Command::new("telnet")
.arg(host)
.arg(port)
@ -507,31 +588,48 @@ impl UI {
.stdout(Stdio::inherit())
.spawn()?;
cmd.wait()?;
out.activate_raw_mode().expect(ERR_RAW_MODE);
terminal::enable_raw_mode()?;
self.dirty = true; // redraw when finished with session
Ok(())
}
/// Asks the current View to process user input and produce an Action.
fn process_view_input(&mut self) -> Action {
if let Some(view) = self.views.get_mut(self.focused) {
if let Ok(key) = stdin()
.keys()
.nth(0)
.ok_or_else(|| Action::Error("stdin.keys() error".to_string()))
{
if let Ok(key) = key {
return view.respond(key);
}
if let Ok(key) = self.keys.lock().unwrap().recv() {
return view.respond(key);
}
}
Action::Error("No Gopher page loaded.".into())
}
/// Listen for keyboard events and send them along.
fn spawn_keyboard_listener() -> KeyReceiver {
let (sender, receiver) = channel();
// Give our resize handler a channel to send events on.
*RESIZE_SENDER.lock().unwrap() = Some(sender.clone());
unsafe {
libc::signal(libc::SIGWINCH, resize_handler as usize);
libc::signal(libc::SIGINT, sigint_handler as usize);
libc::signal(libc::SIGCONT, sigcont_handler as usize);
}
thread::spawn(move || {
for key in stdin().keys().flatten() {
sender.send(key).unwrap();
}
});
Arc::new(Mutex::new(receiver))
}
/// Ctrl-Z: Suspend Unix process w/ SIGTSTP.
fn suspend(&mut self) {
let mut out = self.out.borrow_mut();
terminal::disable_raw_mode().expect("Fatal Error disabling Raw Mode");
let mut out = stdout();
write!(out, "{}", terminal::ToMainScreen).expect(ERR_SCREEN);
out.flush().expect(ERR_STDOUT);
unsafe { libc::raise(libc::SIGTSTP) };
@ -557,7 +655,7 @@ impl UI {
Action::Error(e) => return Err(error!(e)),
Action::Redraw => self.dirty = true,
Action::Draw(s) => {
let mut out = self.out.borrow_mut();
let mut out = stdout();
out.write_all(s.as_ref())?;
out.flush()?;
}
@ -568,6 +666,8 @@ impl UI {
self.process_action(fun(response))?;
}
}
// F5 = redraw the display on resize
Action::Keypress(Key::F(5)) => self.dirty = true,
Action::Keypress(Key::Left) | Action::Keypress(Key::Backspace) => {
if self.focused > 0 {
self.dirty = true;
@ -583,6 +683,29 @@ impl UI {
Action::Keypress(Key::Char(key)) | Action::Keypress(Key::Ctrl(key)) => match key {
'a' => self.open("History", "gopher://phetch/1/history")?,
'b' => self.open("Bookmarks", "gopher://phetch/1/bookmarks")?,
'd' => {
let url = match self.views.get(self.focused) {
Some(view) => String::from(view.url()),
None => return Err(error!("Could not get URL from view")),
};
let url = url.as_str();
if url.starts_with("gopher://phetch/") {
return Err(error!("Can't download internal phetch pages."));
}
let u = gopher::parse_url(&url);
let default_filename = u.sel.split_terminator('/').rev().next().unwrap_or("");
if let Some(filename) = self.prompt("Save to disk as: ", default_filename) {
if filename.trim().is_empty() {
return Err(error!("Please provide a filename."));
}
match self.download_file_with_filename(url, String::from(filename)) {
Ok(()) => (),
Err(e) => return Err(error!("Download failed: {}", e)),
}
}
}
'g' => {
if let Some(url) = self.prompt("Go to URL: ", "") {
self.open(&url, &url)?;
@ -592,16 +715,22 @@ impl UI {
'r' => {
if let Some(view) = self.views.get(self.focused) {
let url = view.url();
let raw = view.raw().to_string();
let mut text = Text::from(url, raw, &self.config, view.is_tls());
let mut text =
Text::from(url, view.raw().into(), self.config.clone(), view.is_tls());
text.wide = true;
self.add_view(Box::new(text));
}
}
'R' => {
if let Some(view) = self.views.get(self.focused) {
let url = view.url().to_owned();
self.open(&url, &url)?;
}
}
's' => {
if let Some(view) = self.views.get(self.focused) {
let url = view.url();
match bookmarks::save(&url, &url) {
match bookmarks::save(url, url) {
Ok(()) => {
let msg = format!("Saved bookmark: {}", url);
self.set_status(&msg);
@ -613,23 +742,22 @@ impl UI {
'u' => {
if let Some(view) = self.views.get(self.focused) {
let current_url = view.url();
if let Some(url) = self.prompt("Current URL: ", &current_url) {
if url != current_url {
self.open(&url, &url)?;
}
if let Some(url) = self.prompt("Current URL: ", current_url) {
self.open(&url, &url)?;
}
}
}
'y' => {
if let Some(view) = self.views.get(self.focused) {
let url = view.url();
utils::copy_to_clipboard(&url)?;
utils::copy_to_clipboard(url)?;
let msg = format!("Copied {} to clipboard.", url);
self.set_status(&msg);
}
}
'w' => {
self.config.wide = !self.config.wide;
let wide = self.config.read().unwrap().wide;
self.config.write().unwrap().wide = !wide;
if let Some(view) = self.views.get_mut(self.focused) {
let w = view.wide();
view.set_wide(!w);
@ -644,11 +772,3 @@ impl UI {
Ok(())
}
}
impl Drop for UI {
fn drop(&mut self) {
let mut out = self.out.borrow_mut();
write!(out, "{}{}", color::Reset, terminal::ShowCursor).expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
}
}

@ -33,11 +33,7 @@ pub enum Action {
impl Action {
/// Is it Action::None?
pub fn is_none(&self) -> bool {
if let Action::None = self {
true
} else {
false
}
matches!(self, Action::None)
}
}

@ -1,6 +1,6 @@
/// The mode our text UI is in. Run mode is the default while
/// Print doesn't show the cursor, among other things.
#[derive(Debug, PartialEq, Copy, Clone)]
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
pub enum Mode {
/// Default, interactive mode.
/// phetch URL
@ -21,6 +21,9 @@ pub enum Mode {
/// Show command line help.
/// phetch --help
Help,
/// Print current theme
/// phetch --theme
PrintTheme,
}
impl Default for Mode {

@ -1,5 +1,7 @@
use crate::ui;
use std::fmt;
use {
crate::{encoding::Encoding, ui},
std::fmt,
};
/// Views represent what's on screen, a Gopher Menu/Text/etc item.
pub trait View: fmt::Display {
@ -23,4 +25,8 @@ pub trait View: fmt::Display {
fn wide(&mut self) -> bool;
/// Set the current screen size.
fn term_size(&mut self, cols: usize, rows: usize);
/// The current encoding.
fn encoding(&self) -> Encoding {
Encoding::default()
}
}

@ -1,5 +1,6 @@
//! Helper functions and macros.
use std::{
borrow::Cow,
io::{Result, Write},
process::{self, Stdio},
};
@ -17,8 +18,8 @@ macro_rules! log {
.open("phetch.log")
{
use std::io::prelude::*;
file.write($e.as_ref());
file.write(b"\n");
file.write($e.as_ref()).unwrap();
file.write(b"\n").unwrap();
}
}
}};
@ -35,7 +36,7 @@ macro_rules! error {
std::io::Error::new(std::io::ErrorKind::Other, $e)
};
($e:expr, $($y:expr),*) => {
error!(format!($e, $($y),*));
error!(format!($e, $($y),*))
};
}
@ -95,3 +96,44 @@ pub fn open_external(url: &str) -> Result<()> {
))
}
}
/// Opens a media file with `mpv` or `--media`.
pub fn open_media(program: &str, url: &str) -> Result<()> {
use {crate::terminal, std::io};
// mpv only supports /9/
let url = if program.ends_with("mpv") {
Cow::from(url.replace("/;/", "/9/").replace("/s/", "/9/"))
} else {
Cow::from(url)
};
// support URL: selectors
let url = if let Some(idx) = url.find("URL:") {
url.split_at(idx).1.trim_start_matches("URL:")
} else {
&url
};
let errfn = |e| {
terminal::enable_raw_mode().unwrap();
error!("Media player error: {}", e)
};
// clear screen first
let mut stdout = io::stdout();
write!(stdout, "{}{}", terminal::ClearAll, terminal::Goto(1, 1))?;
stdout.flush()?;
terminal::disable_raw_mode()?;
let mut cmd = process::Command::new(program)
.arg(url)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.spawn()
.map_err(errfn)?;
cmd.wait().map_err(errfn)?;
terminal::enable_raw_mode()?;
Ok(())
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@ -0,0 +1,19 @@
CP437 (Extended ASCII)
<EFBFBD>ֲֲֲֲֲֲֲֲֲֲֲֲֲֲֲִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִ¿
³ € ³ <20> ³ ³ ƒ ³ „ ³ … ³ † ³ ‡ ³ ˆ ³ ‰ ³ <20> ³ ³ <20> ³ <20> ³ <20> ³ <20> ³
ֳִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִֵֵֵֵֵֵֵֵֵֵֵֵֵֵֵ´
³ <20> ³ ³ ³ “ ³ ” ³ • ³ ³ — ³ ˜ ³ ™ ³ <20> ³ ³ <20> ³ <20> ³ <20> ³ <20> ³
ֳִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִֵֵֵֵֵֵֵֵֵֵֵֵֵֵֵ´
³   ³ ¡ ³ ¢ ³ £ ³ ₪ ³ ¥ ³ ¦ ³ § ³ ¨ ³ © ³ × ³ « ³ ¬ ³ ­ ³ ® ³ ¯ ³
ֳִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִֵֵֵֵֵֵֵֵֵֵֵֵֵֵֵ´
³ ° ³ ± ³ ² ³ ³ ³ ´ ³ µ ³ ¶ ³ · ³ ¸ ³ ¹ ³ ÷ ³ » ³ ¼ ³ ½ ³ ¾ ³ ¿ ³
ֳִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִֵֵֵֵֵֵֵֵֵֵֵֵֵֵֵ´
³ ְ ³ ֱ ³ ֲ ³ ֳ ³ ִ ³ ֵ ³ ֶ ³ ַ ³ ָ ³ ֹ ³ ֺ ³ ֻ ³ ּ ³ ֽ ³ ־ ³ ֿ ³
ֳִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִֵֵֵֵֵֵֵֵֵֵֵֵֵֵֵ´
³ ׀ ³ ׁ ³ ׂ ³ ׃ ³ װ ³ ױ ³ ײ ³ ׳ ³ ״ ³ <20> ³ <20> ³ <20> ³ <20> ³ <20> ³ <20> ³ <20> ³
ֳִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִֵֵֵֵֵֵֵֵֵֵֵֵֵֵֵ´
³ א ³ ב ³ ג ³ ד ³ ה ³ ו ³ ז ³ ח ³ ט ³ י ³ ך ³ כ ³ ל ³ ם ³ מ ³ ן ³
ֳִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִֵֵֵֵֵֵֵֵֵֵֵֵֵֵֵ´
³ נ ³ ס ³ ע ³ ף ³ פ ³ ץ ³ צ ³ ק ³ ר ³ ש ³ ת ³ <20> ³ <20> ³ ³ ³ <20> ³
ְֱֱֱֱֱֱֱֱֱֱֱֱֱֱֱִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִִ<EFBFBD>

@ -0,0 +1,15 @@
# phetch theme
ui.cursor red
ui.number white
ui.menu cyan
ui.text white
item.text magenta
item.menu magenta
item.error red
item.search magenta
item.telnet magenta
item.external magenta
item.download magenta underline
item.media magenta underline
item.unsupported magentabg white

@ -0,0 +1,14 @@
# phetch default theme
ui.cursor white bold
ui.number magenta
ui.menu yellow
ui.text white
item.text cyan
item.menu blue
item.error red
item.search white
item.telnet grey
item.external green
item.download white underline
item.media green underline
item.unsupported whitebg red

@ -0,0 +1,15 @@
# phetch light theme
ui.cursor black bold
ui.number magenta
ui.menu darkyellow
ui.text black
item.text darkcyan
item.menu blue
item.error red
item.search black
item.telnet grey
item.external darkgreen
item.download black underline
item.media darkgreen underline
item.unsupported redbg white

@ -0,0 +1,15 @@
# phetch matrix theme
ui.cursor yellow
ui.number grey
ui.menu green
ui.text green
item.text green
item.menu green
item.error red
item.search green
item.telnet green
item.external green
item.download green underline
item.media green underline
item.unsupported magentabg white

@ -0,0 +1,15 @@
# phetch vaporwave(ish) theme
ui.cursor red
ui.number white
ui.menu cyan
ui.text white
item.text magenta
item.menu magenta
item.error red
item.search magenta
item.telnet magenta
item.external magenta
item.download magenta underline
item.media magenta underline
item.unsupported magentabg white

@ -0,0 +1,15 @@
# phetch web theme
ui.cursor white bold
ui.number white
ui.menu white
ui.text white
item.text blue underline
item.menu blue underline
item.error red
item.search blue underline
item.telnet blue underline
item.external blue underline
item.download blue underline
item.media blue underline
item.unsupported whitebg red
Loading…
Cancel
Save