Browse Source

adding link selection, status bar, cleaned up media opener

Benton Edmondson 1 year ago
parent
commit
f663096c17

+ 10 - 7
config/config.go

@@ -8,24 +8,27 @@ import (
 )
 
 type Config struct {
-	Context int
-	Timeout int
-	Feeds   feeds
-	Algos   algos
+	Context int `toml:"context"`
+	Timeout int `toml:"timeout"`
+	Feeds   feeds `toml:"feeds"`
+	Algos   algos `toml:"algos"`
+	MediaHook   []string `toml:"media_hook"`
 }
 
 type feeds = map[string][]string
 type algos = map[string]struct {
-	Server string
-	Query  string
+	Server string `toml:"server"`
+	Query  string `toml:"query"`
 }
 
 func Parse() (*Config, error) {
+	/* Default values */
 	config := &Config{
 		Context: 5,
 		Timeout: 10,
 		Feeds:   feeds{},
 		Algos:   algos{},
+		MediaHook:   []string{"xdg-open", "%u"},
 	}
 
 	location := location()
@@ -42,7 +45,7 @@ func Parse() (*Config, error) {
 	}
 
 	if undecoded := metadata.Undecoded(); len(undecoded) != 0 {
-		return nil, fmt.Errorf("config file %s contained unexpected keys: %v", location, undecoded)
+		return nil, fmt.Errorf("config file %s contained unrecognized keys: %v", location, undecoded)
 	}
 
 	return config, nil

+ 19 - 5
gemtext/gemtext.go

@@ -11,8 +11,21 @@ import (
 	https://gemini.circumlunar.space/docs/specification.html
 */
 
-func Render(text string, width int) (string, error) {
+type Markup []string
+
+func NewMarkup(text string) (*Markup, []string, error) {
 	lines := strings.Split(text, "\n")
+	_, links := renderWithLinks(lines, 80)
+	return (*Markup)(&lines), links, nil
+}
+
+func (m Markup) Render(width int) string {
+	rendered, _ := renderWithLinks(([]string)(m), width)
+	return rendered
+}
+
+func renderWithLinks(lines []string, width int) (string, []string) {
+	links := []string{}
 	result := ""
 	preformattedMode := false
 	preformattedBuffer := ""
@@ -34,12 +47,13 @@ func Render(text string, width int) (string, error) {
 		}
 
 		if match := regexp.MustCompile(`^=>[ \t]*(.*?)(?:[ \t]+(.*))?$`).FindStringSubmatch(line); len(match) == 3 {
-			url := match[1]
+			uri := match[1]
 			alt := match[2]
 			if alt == "" {
-				alt = url
+				alt = uri
 			}
-			result += style.LinkBlock(alt) + "\n"
+			links = append(links, uri)
+			result += style.LinkBlock(alt, len(links)) + "\n"
 		} else if match := regexp.MustCompile(`^#[ \t]+(.*)$`).FindStringSubmatch(line); len(match) == 2 {
 			result += style.Header(match[1], 1) + "\n"
 		} else if match := regexp.MustCompile(`^##[ \t]+(.*)$`).FindStringSubmatch(line); len(match) == 2 {
@@ -60,5 +74,5 @@ func Render(text string, width int) (string, error) {
 		result += style.CodeBlock(strings.TrimSuffix(preformattedBuffer, "\n")) + "\n"
 	}
 
-	return strings.TrimSuffix(result, "\n"), nil
+	return strings.Trim(result, "\n"), links
 }

+ 16 - 6
gemtext/gemtext_test.go

@@ -2,7 +2,6 @@ package gemtext
 
 import (
 	"mimicry/style"
-	"mimicry/util"
 	"testing"
 )
 
@@ -20,19 +19,30 @@ func TestBasic(t *testing.T) {
 =>http://example.org/
 
 ` + "```\ncode block\nhere\n```"
-	output, err := Render(input, 50)
+	markup, links, err := NewMarkup(input)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
 	}
 
+	if links[0] != "https://www.wikipedia.org/" {
+		t.Fatalf("first link should be https://www.wikipedia.org/ not %s", links[0])
+	}
+
+	if links[1] != "http://example.org/" {
+		t.Fatalf("second link should be http://example.org/ not %s", links[1])
+	}
+	
+	output := markup.Render(50)
 	expected := style.QuoteBlock("blockquote") + "\n\n" +
 		style.Bullet("bullet point") + "\n\n" +
 		style.Header("large header", 1) + "\n" +
 		style.Header("smaller header", 2) + "\n" +
 		style.Header("smallest header", 3) + "\n\n" +
-		style.LinkBlock("Wikipedia is great!") + "\n\n" +
-		style.LinkBlock("http://example.org/") + "\n\n" +
+		style.LinkBlock("Wikipedia is great!", 1) + "\n\n" +
+		style.LinkBlock("http://example.org/", 2) + "\n\n" +
 		style.CodeBlock("code block\nhere")
 
-	util.AssertEqual(expected, output, t)
+	if expected != output {
+		t.Fatalf("expected %s not %s", expected, output)
+	}
 }

+ 136 - 139
hypertext/hypertext.go

@@ -1,7 +1,6 @@
 package hypertext
 
 import (
-	"errors"
 	"golang.org/x/net/html"
 	"golang.org/x/net/html/atom"
 	"mimicry/ansi"
@@ -10,47 +9,45 @@ import (
 	"strings"
 )
 
-// TODO: create a `bulletedList` function for all situations where
-// html-specific wrapping is needed. Put them at the bottom of the file
-// and note that that section is different. (This will include one for
-// headers)
+type Markup []*html.Node
 
-// TODO: blocks need to be trimmed on the inside and newlined on
-// the outside
-
-/*
-Terminal codes and control characters should already be escaped
+type context struct {
+	preserveWhitespace bool
+	width int
+	links *[]string
+}
 
-	by this point
-*/
-func Render(text string, width int) (string, error) {
+func NewMarkup(text string) (*Markup, []string, error) {
 	nodes, err := html.ParseFragment(strings.NewReader(text), &html.Node{
 		Type:     html.ElementNode,
 		Data:     "body",
 		DataAtom: atom.Body,
 	})
 	if err != nil {
-		return "", err
-	}
-	rendered, err := renderList(nodes, width)
-	if err != nil {
-		return "", err
+		return nil, []string{}, err
 	}
+	_, links := renderWithLinks(nodes, 80)
+	return (*Markup)(&nodes), links, nil
+}
 
-	wrapped := ansi.Wrap(rendered, width)
-	return strings.Trim(wrapped, " \n"), nil
+func (m Markup) Render(width int) string {
+	rendered, _ := renderWithLinks(([]*html.Node)(m), width)
+	return rendered
 }
 
-func renderList(nodes []*html.Node, width int) (string, error) {
+func renderWithLinks(nodes []*html.Node, width int) (string, []string) {
+	ctx := context{
+		preserveWhitespace: false,
+		width: width,
+		links: &[]string{},
+	}
 	output := ""
 	for _, current := range nodes {
-		result, err := renderNode(current, width, false)
-		if err != nil {
-			return "", err
-		}
+		result := renderNode(current, ctx)
 		output = mergeText(output, result)
 	}
-	return output, nil
+	output = ansi.Wrap(output, width)
+	return strings.Trim(output, " \n"), *ctx.links
 }
 
 /*
@@ -82,182 +79,181 @@ func mergeText(lhs string, rhs string) string {
 		return lhsTrimmed + rhsTrimmed
 	}
 
-	switch strings.Count(whitespace, "\n") {
-	case 0:
+	newlineCount := strings.Count(whitespace, "\n")
+
+	if newlineCount == 0 {
 		return lhsTrimmed + " " + rhsTrimmed
-	case 1:
+	}
+
+	if newlineCount == 1 {
 		return lhsTrimmed + "\n" + rhsTrimmed
 	}
 
 	return lhsTrimmed + "\n\n" + rhsTrimmed
 }
 
-func renderNode(node *html.Node, width int, preserveWhitespace bool) (string, error) {
+func renderNode(node *html.Node, ctx context) string {
 	if node.Type == html.TextNode {
-		if !preserveWhitespace {
+		if !ctx.preserveWhitespace {
 			whitespace := regexp.MustCompile(`[ \t\n\r]+`)
-			return whitespace.ReplaceAllString(node.Data, " "), nil
+			return whitespace.ReplaceAllString(node.Data, " ")
 		}
-		return node.Data, nil
+		return node.Data
 	}
 
 	if node.Type != html.ElementNode {
-		return "", nil
-	}
-
-	content, err := renderChildren(node, width, preserveWhitespace)
-	if err != nil {
-		return "", err
+		return ""
 	}
 
 	switch node.Data {
 	case "a":
-		return style.Link(content), nil
+		link := getAttribute("href", node.Attr)
+		if link == "" {
+			return renderChildren(node, ctx)
+		}
+		*ctx.links = append(*ctx.links, link)
+		/* This must occur before the styling because it mutates ctx.links */
+		rendered := renderChildren(node, ctx)
+		return style.Link(rendered, len(*ctx.links))
 	case "s", "del":
-		return style.Strikethrough(content), nil
+		return style.Strikethrough(renderChildren(node, ctx))
 	case "code":
-		return style.Code(content), nil
+		ctx.preserveWhitespace = true
+		return style.Code(renderChildren(node, ctx))
 	case "i", "em":
-		return style.Italic(content), nil
+		return style.Italic(renderChildren(node, ctx))
 	case "b", "strong":
-		return style.Bold(content), nil
+		return style.Bold(renderChildren(node, ctx))
 	case "u", "ins":
-		return style.Underline(content), nil
+		return style.Underline(renderChildren(node, ctx))
 	case "mark":
-		return style.Highlight(content), nil
+		return style.Highlight(renderChildren(node, ctx))
 	case "span", "li", "small":
-		return content, nil
+		return renderChildren(node, ctx)
 	case "br":
-		return "\n", nil
+		return "\n"
 
 	case "p", "div":
-		return block(content), nil
+		return block(renderChildren(node, ctx))
 	case "pre":
-		content, err := renderChildren(node, width-2, true)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width, true)
-		return block(style.CodeBlock(wrapped)), err
+		ctx.preserveWhitespace = true
+		wrapped := ansi.Pad(situationalWrap(renderChildren(node, ctx), ctx), ctx.width)
+		return block(style.CodeBlock(wrapped))
 	case "blockquote":
-		content, err := renderChildren(node, width-1, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width-1, preserveWhitespace)
-		// TODO: this text wrap is ugly
-		return block(style.QuoteBlock(strings.Trim(wrapped, " \n"))), nil
+		ctx.width -= 1
+		wrapped := situationalWrap(renderChildren(node, ctx), ctx)
+		return block(style.QuoteBlock(strings.Trim(wrapped, " \n")))
 	case "ul":
-		list, err := bulletedList(node, width, preserveWhitespace)
-		return list, err
+		return bulletedList(node, ctx)
 	// case "ul":
-	// 	return numberedList(node), nil
-
+	// 	return numberedList(node)
 	case "h1":
-		content, err := renderChildren(node, width-2, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width-2, preserveWhitespace)
-		return block(style.Header(wrapped, 1)), nil
+		ctx.width -= 2
+		wrapped := situationalWrap(renderChildren(node, ctx), ctx)
+		return block(style.Header(wrapped, 1))
 	case "h2":
-		content, err := renderChildren(node, width-3, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width-3, preserveWhitespace)
-		return block(style.Header(wrapped, 2)), nil
+		ctx.width -= 3
+		wrapped := situationalWrap(renderChildren(node, ctx), ctx)
+		return block(style.Header(wrapped, 2))
 	case "h3":
-		content, err := renderChildren(node, width-4, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width-4, preserveWhitespace)
-		return block(style.Header(wrapped, 3)), nil
+		ctx.width -= 4
+		wrapped := situationalWrap(renderChildren(node, ctx), ctx)
+		return block(style.Header(wrapped, 3))
 	case "h4":
-		content, err := renderChildren(node, width-5, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width-5, preserveWhitespace)
-		return block(style.Header(wrapped, 4)), nil
+		ctx.width -= 5
+		wrapped := situationalWrap(renderChildren(node, ctx), ctx)
+		return block(style.Header(wrapped, 4))
 	case "h5":
-		content, err := renderChildren(node, width-6, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width-6, preserveWhitespace)
-		return block(style.Header(wrapped, 5)), nil
+		ctx.width -= 6
+		wrapped := situationalWrap(renderChildren(node, ctx), ctx)
+		return block(style.Header(wrapped, 5))
 	case "h6":
-		content, err := renderChildren(node, width-7, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width-7, preserveWhitespace)
-		return block(style.Header(wrapped, 6)), nil
-
+		ctx.width -= 7
+		wrapped := situationalWrap(renderChildren(node, ctx), ctx)
+		return block(style.Header(wrapped, 6))
 	case "hr":
-		return block(strings.Repeat("―", width)), nil
-	case "img", "video", "audio", "iframe":
-		text := getAttribute("alt", node.Attr)
-		if text == "" {
-			text = getAttribute("title", node.Attr)
+		return block(style.Color(strings.Repeat("\u23AF", ctx.width)))
+
+	/*
+		The spec does not define the alt attribute for videos nor audio.
+		I think it should, so if present I display it. It is
+		tempting to use the children of the video and audio tags for
+		this purpose, but it looks like they exist more so for backwards
+		compatibility, so should contain something like "your browser does
+		not support inline video; click here" as opposed to actual alt
+		text.
+	*/
+	case "img", "video", "audio":
+		alt := getAttribute("alt", node.Attr)
+		link := getAttribute("src", node.Attr)
+		if alt == "" {
+			alt = link
+		}
+		if link == "" {
+			return block(alt)
 		}
-		if text == "" {
-			text = getAttribute("src", node.Attr)
+		*ctx.links = append(*ctx.links, link)
+		ctx.width -= 2
+		wrapped := situationalWrap(alt, ctx)
+		return block(style.LinkBlock(wrapped, len(*ctx.links)))
+	case "iframe":
+		alt := getAttribute("title", node.Attr)
+		link := getAttribute("src", node.Attr)
+		if alt == "" {
+			alt = link
 		}
-		if text == "" {
-			return "", errors.New(node.Data + " tag is missing both `alt` and `src` attributes")
+		if link == "" {
+			return block(alt)
 		}
-		wrapped := situationalWrap(text, width-2, preserveWhitespace)
-		return block(style.LinkBlock(wrapped)), nil
+		*ctx.links = append(*ctx.links, link)
+		ctx.width -= 2
+		wrapped := situationalWrap(alt, ctx)
+		return block(style.LinkBlock(wrapped, len(*ctx.links)))
+	default:
+		return bad(node, ctx)
 	}
-
-	return "", errors.New("Encountered unrecognized element " + node.Data)
 }
 
-func renderChildren(node *html.Node, width int, preserveWhitespace bool) (string, error) {
+func renderChildren(node *html.Node, ctx context) string {
 	output := ""
 	for current := node.FirstChild; current != nil; current = current.NextSibling {
-		result, err := renderNode(current, width, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
+		result := renderNode(current, ctx)
 		output = mergeText(output, result)
 	}
-	return output, nil
+	return output
 }
 
 func block(text string) string {
 	return "\n\n" + strings.Trim(text, " \n") + "\n\n"
 }
 
-func bulletedList(node *html.Node, width int, preserveWhitespace bool) (string, error) {
+func bulletedList(node *html.Node, ctx context) string {
 	output := ""
+	ctx.width -= 2
 	for current := node.FirstChild; current != nil; current = current.NextSibling {
 		if current.Type != html.ElementNode {
 			continue
 		}
 
+		result := ""
 		if current.Data != "li" {
-			continue
+			result = bad(current, ctx)
+		} else {
+			result = renderNode(current, ctx)
 		}
 
-		result, err := renderNode(current, width-2, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(result, width-2, preserveWhitespace)
+		wrapped := situationalWrap(result, ctx)
 		output += "\n" + style.Bullet(wrapped)
 	}
 
-	if node.Parent == nil {
-		return block(output), nil
-	} else if node.Parent.Data == "li" {
-		return output, nil
-	} else {
-		return block(output), nil
+	if node.Parent != nil && node.Parent.Data == "li" {
+		return output
 	}
+	return block(output)
+}
+
+func bad(node *html.Node, ctx context) string {
+	return style.Red("<" + node.Data + ">") + renderChildren(node, ctx) + style.Red("</" + node.Data + ">")
 }
 
 func getAttribute(name string, attributes []html.Attribute) string {
@@ -269,10 +265,11 @@ func getAttribute(name string, attributes []html.Attribute) string {
 	return ""
 }
 
-func situationalWrap(text string, width int, preserveWhitespace bool) string {
-	if preserveWhitespace {
-		return ansi.DumbWrap(text, width)
+func situationalWrap(text string, ctx context) string {
+	if ctx.preserveWhitespace {
+		// TODO: I should probably change DumbWrap to truncate (which just lets it go off the end of the screen)
+		return ansi.DumbWrap(text, ctx.width)
 	}
 
-	return ansi.Wrap(text, width)
+	return ansi.Wrap(text, ctx.width)
 }

+ 148 - 38
hypertext/hypertext_test.go

@@ -2,8 +2,8 @@ package hypertext
 
 import (
 	"mimicry/style"
-	"mimicry/util"
 	"testing"
+	"mimicry/ansi"
 )
 
 func TestMergeText(t *testing.T) {
@@ -11,32 +11,44 @@ func TestMergeText(t *testing.T) {
 	rhs0 := "back"
 	output0 := mergeText(lhs0, rhs0)
 	expected0 := "frontback"
-	util.AssertEqual(expected0, output0, t)
+	if expected0 != output0 {
+		t.Fatalf("expected %s not %s", expected0, output0)
+	}
 
 	lhs1 := "front     "
 	rhs1 := "   back"
 	output1 := mergeText(lhs1, rhs1)
 	expected1 := "front back"
-	util.AssertEqual(expected1, output1, t)
+	if expected1 != output1 {
+		t.Fatalf("expected %s not %s", expected1, output1)
+	}
 
 	lhs2 := "front     "
 	rhs2 := " \n  back"
 	output2 := mergeText(lhs2, rhs2)
 	expected2 := "front\nback"
-	util.AssertEqual(expected2, output2, t)
+	if expected2 != output2 {
+		t.Fatalf("expected %s not %s", expected2, output2)
+	}
 
 	lhs3 := "front    \n\n\n "
 	rhs3 := " \n  back"
 	output3 := mergeText(lhs3, rhs3)
 	expected3 := "front\n\nback"
-	util.AssertEqual(expected3, output3, t)
+	if expected3 != output3 {
+		t.Fatalf("expected %s not %s", expected3, output3)
+	}
 }
 
 func TestStyles(t *testing.T) {
 	input := "<s>s</s><code>code</code><i>i</i><u>u</u><mark>mark</mark>"
-	output, err := Render(input, 50)
+	markup, _, err := NewMarkup(input)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
+	}
+	output := markup.Render(50)
+	if err != nil {
+		t.Fatal(err)
 	}
 	expected := style.Strikethrough("s") +
 		style.Code("code") +
@@ -44,65 +56,88 @@ func TestStyles(t *testing.T) {
 		style.Underline("u") +
 		style.Highlight("mark")
 
-	util.AssertEqual(expected, output, t)
+	if expected != output {
+		t.Fatalf("excpected output to be %s not %s", expected, output)
+	}
 }
 
 func TestSurroundingBlocks(t *testing.T) {
 	input := "<p>first</p>in \t<mark>the</mark> \rmiddle<p>last</p>"
-	output, err := Render(input, 50)
+	markup, _, err := NewMarkup(input)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
+	}
+	output := markup.Render(50)
+	if err != nil {
+		t.Fatal(err)
 	}
 	expected := `first
 
 in ` + style.Highlight("the") + ` middle
 
 last`
-	util.AssertEqual(expected, output, t)
+	if expected != output {
+		t.Fatalf("excpected output to be %s not %s", expected, output)
+	}
 }
 
 func TestAdjacentBlocks(t *testing.T) {
 	input := "\t<p>first</p>\n\t<p>second</p>"
-	output, err := Render(input, 50)
+	markup, _, err := NewMarkup(input)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
+	}
+	output := markup.Render(50)
+	if err != nil {
+		t.Fatal(err)
 	}
 	expected := `first
 
 second`
-	util.AssertEqual(expected, output, t)
+	if expected != output {
+		t.Fatalf("excpected output to be %s not %s", expected, output)
+	}
 }
 
 func TestPoetry(t *testing.T) {
 	input := "he shouted\t\ta few words<br>at those annoying birds<br><br>and that they heard"
-	output, err := Render(input, 50)
+	markup, _, err := NewMarkup(input)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
+	}
+	output := markup.Render(50)
+	if err != nil {
+		t.Fatal(err)
 	}
 	expected := `he shouted a few words
 at those annoying birds
 
 and that they heard`
 
-	util.AssertEqual(expected, output, t)
+	if expected != output {
+		t.Fatalf("excpected output to be %s not %s", expected, output)
+	}
 }
 
-// TODO: this is broken for now because my wrap algorithm removes
-// trailing spaces under certain conditions. I need to modify it such that it
-// leaves trailing spaces if possible
 func TestPreservation(t *testing.T) {
 	input := "<pre>multi-space   \n\n\n\n\n far down</pre>"
-	output, err := Render(input, 50)
+	markup, _, err := NewMarkup(input)
+	if err != nil {
+		t.Fatal(err)
+	}
+	output := markup.Render(50)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
 	}
-	expected := style.CodeBlock(`multi-space   
+	expected := style.CodeBlock(ansi.Pad(`multi-space   
 
 
 
 
- far down`)
-	util.AssertEqual(expected, output, t)
+ far down`, 50))
+	if expected != output {
+		t.Fatalf("excpected output to be %s not %s", expected, output)
+	}
 }
 
 func TestNestedBlocks(t *testing.T) {
@@ -111,45 +146,120 @@ func TestNestedBlocks(t *testing.T) {
 <p> </p>
 
 <p><img src="https://i.snap.as/P8qpdMbM.jpg" alt=""/></p>`
-	output, err := Render(input, 50)
+	markup, _, err := NewMarkup(input)
+	if err != nil {
+		t.Fatal(err)
+	}
+	output := markup.Render(50)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
 	}
 	expected := `Once a timid child
 
-` + style.LinkBlock("https://i.snap.as/P8qpdMbM.jpg")
-	util.AssertEqual(expected, output, t)
+` + style.LinkBlock("https://i.snap.as/P8qpdMbM.jpg", 1)
+	if expected != output {
+		t.Fatalf("excpected output to be %s not %s", expected, output)
+	}
 }
 
 func TestAdjacentLists(t *testing.T) {
 	input := `<ul><li>top list</li></ul><ul><li>bottom list</li></ul>`
-	output, err := Render(input, 50)
+	markup, _, err := NewMarkup(input)
+	if err != nil {
+		t.Fatal(err)
+	}
+	output := markup.Render(50)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
 	}
 	expected := style.Bullet("top list") + "\n\n" +
 		style.Bullet("bottom list")
-	util.AssertEqual(expected, output, t)
+	if expected != output {
+		t.Fatalf("excpected output to be %s not %s", expected, output)
+	}
 }
 
 func TestNestedLists(t *testing.T) {
 	input := `<ul><li>top list<ul><li>nested</li></ul></li></ul>`
-	output, err := Render(input, 50)
+	markup, _, err := NewMarkup(input)
+	if err != nil {
+		t.Fatal(err)
+	}
+	output := markup.Render(50)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
 	}
 	expected := style.Bullet("top list\n" + style.Bullet("nested"))
 
-	util.AssertEqual(expected, output, t)
+	if expected != output {
+		t.Fatalf("excpected output to be %s not %s", expected, output)
+	}
 }
 
 func TestBlockInList(t *testing.T) {
 	input := `<ul><li>top list<p><ul><li>paragraph</li></ul></p></li></ul>`
-	output, err := Render(input, 50)
+	markup, _, err := NewMarkup(input)
+	if err != nil {
+		t.Fatal(err)
+	}
+	output := markup.Render(50)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
 	}
 	expected := style.Bullet("top list\n\n" + style.Bullet("paragraph"))
 
-	util.AssertEqual(expected, output, t)
+	if expected != output {
+		t.Fatalf("excpected output to be %s not %s", expected, output)
+	}
+}
+
+func TestWrapping(t *testing.T) {
+	input := `<p>hello sir</p>`
+	markup, _, err := NewMarkup(input)
+	if err != nil {
+		t.Fatal(err)
+	}
+	output := markup.Render(4)
+	if err != nil {
+		t.Fatal(err)
+	}
+	expected := "hell\no\nsir"
+
+	if expected != output {
+		t.Fatalf("excpected output to be %s not %s", expected, output)
+	}
+}
+
+func TestLinks(t *testing.T) {
+	input := `<a href="https://wikipedia.org">Great site</a>
+<img src="https://example.org" alt="What the heck">
+<iframe title="Music" src="https://spotify.com">`
+	markup, links, err := NewMarkup(input)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if links[0] != "https://wikipedia.org" {
+		t.Fatalf("the first links should have been https://wikipedia.org not %s", links[0])
+	}
+
+	if links[1] != "https://example.org" {
+		t.Fatalf("the first links should have been https://example.org not %s", links[1])
+	}
+
+	if links[2] != "https://spotify.com" {
+		t.Fatalf("the first links should have been https://spotify.com not %s", links[2])
+	}
+
+	output := markup.Render(50)
+	if err != nil {
+		t.Fatal(err)
+	}
+	expected := style.Link("Great site", 1) + "\n\n" +
+		style.LinkBlock("What the heck", 2) + "\n\n" +
+		style.LinkBlock("Music", 3)
+
+	if expected != output {
+		t.Fatalf("excpected output to be %s not %s", expected, output)
+	}
 }

+ 16 - 29
main.go

@@ -13,6 +13,17 @@ import (
 // TODO: clean up most panics
 
 func main() {
+	if len(os.Args) < 3 {
+		help()
+		return
+	}
+
+	config, err := config.Parse()
+	if err != nil {
+		os.Stderr.WriteString(err.Error() + "\n")
+		return
+	}
+
 	oldTerminal, err := term.MakeRaw(int(os.Stdin.Fd()))
 	if err != nil {
 		panic(err)
@@ -22,38 +33,15 @@ func main() {
 	if err != nil {
 		panic(err)
 	}
-	config, err := config.Parse()
-	if err != nil {
-		panic(err)
-	}
-	state := ui.NewState(config, width, height, printRaw)
 
-	if len(os.Args) < 2 {
+	state := ui.NewState(config, width, height, printRaw)
+	err = state.Subcommand(os.Args[1], os.Args[2])
+	if err != nil {
+		term.Restore(int(os.Stdin.Fd()), oldTerminal)
 		help()
 		return
 	}
 
-	switch os.Args[1] {
-	case "open":
-		if len(os.Args) == 3 {
-			state.Open(os.Args[2])
-		} else {
-			help()
-			return
-		}
-	case "feed":
-		if len(os.Args) == 2 {
-			state.Feed("default")
-		} else if len(os.Args) == 3 {
-			state.Feed(os.Args[2])
-		} else {
-			help()
-			return
-		}
-	default:
-		panic("expected a command as the first argument")
-	}
-
 	go func() {
 		for {
 			time.Sleep(500 * time.Millisecond)
@@ -74,8 +62,7 @@ func main() {
 			printRaw("")
 			return
 		}
-
-		state.Update(input)
+		go state.Update(input)
 	}
 }
 

+ 10 - 8
markdown/markdown.go

@@ -5,20 +5,22 @@ import (
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/extension"
 	"mimicry/hypertext"
-	"strings"
 )
 
 var renderer = goldmark.New(goldmark.WithExtensions(extension.GFM))
 
-func Render(text string, width int) (string, error) {
+type Markup hypertext.Markup
+
+func NewMarkup(text string) (*Markup, []string, error) {
 	var buf bytes.Buffer
 	if err := renderer.Convert([]byte(text), &buf); err != nil {
-		return "", nil
+		return nil, []string{}, err
 	}
 	output := buf.String()
-	rendered, err := hypertext.Render(output, width)
-	if err != nil {
-		return "", err
-	}
-	return strings.TrimSpace(rendered), nil
+	hypertextMarkup, links, err := hypertext.NewMarkup(output)
+	return (*Markup)(hypertextMarkup), links, err
+}
+
+func (m *Markup) Render(width int) string {
+	return (*hypertext.Markup)(m).Render(width)
 }

+ 17 - 7
markdown/markdown_test.go

@@ -2,25 +2,35 @@ package markdown
 
 import (
 	"mimicry/style"
-	"mimicry/util"
 	"testing"
 )
 
 func TestBasic(t *testing.T) {
 	input := `[Here's a link!](https://wikipedia.org)
 
-![This is a beautiful image!](https://upload.wikimedia.org/wikipedia/commons/thumb/c/cb/Francesco_Melzi_-_Portrait_of_Leonardo.png/800px-Francesco_Melzi_-_Portrait_of_Leonardo.png)
+![This is a beautiful image!](https://miro.medium.com/v2/resize:fit:900/0*L31Zh4YhAv3Wokco)
 
 * Nested list
   * Nesting`
-	output, err := Render(input, 50)
+	markup, links, err := NewMarkup(input)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
 	}
 
-	expected := style.Link("Here's a link!") + "\n\n" +
-		style.LinkBlock("This is a beautiful image!") + "\n\n" +
+	if links[0] != "https://wikipedia.org" {
+		t.Fatalf("first link should be https://wikipedia.org not %s", links[0])
+	}
+
+	if links[1] != "https://miro.medium.com/v2/resize:fit:900/0*L31Zh4YhAv3Wokco" {
+		t.Fatalf("second link should be https://miro.medium.com/v2/resize:fit:900/0*L31Zh4YhAv3Wokco not %s", links[1])
+	}
+
+	output := markup.Render(50)
+	expected := style.Link("Here's a link!", 1) + "\n\n" +
+		style.LinkBlock("This is a beautiful image!", 2) + "\n\n" +
 		style.Bullet("Nested list\n"+style.Bullet("Nesting"))
 
-	util.AssertEqual(expected, output, t)
+	if expected != output {
+		t.Fatalf("expected %s not %s", expected, output)
+	}
 }

+ 16 - 0
mime/mime.go

@@ -24,6 +24,22 @@ func Default() *MediaType {
 	}
 }
 
+func Unknown() *MediaType {
+	return &MediaType {
+		Essence: "*/*",
+		Supertype: "*",
+		Subtype: "*",
+	}
+}
+
+func UnknownSubtype(supertype string) *MediaType {
+	return &MediaType {
+		Essence: supertype + "/*",
+		Supertype: supertype,
+		Subtype: "*",
+	}
+}
+
 func Parse(input string) (*MediaType, error) {
 	matches := re.FindStringSubmatch(input)
 

+ 35 - 29
object/object.go

@@ -6,6 +6,10 @@ import (
 	"mimicry/mime"
 	"net/url"
 	"time"
+	"mimicry/plaintext"
+	"mimicry/hypertext"
+	"mimicry/markdown"
+	"mimicry/gemtext"
 )
 
 type Object map[string]any
@@ -32,7 +36,14 @@ func (o Object) GetAny(key string) (any, error) {
 }
 
 func (o Object) GetString(key string) (string, error) {
-	return getPrimitive[string](o, key)
+	value, err := getPrimitive[string](o, key)
+	if err != nil {
+		return "", err
+	}
+	if value == "" {
+		return "", ErrKeyNotPresent
+	}
+	return value, nil
 }
 
 // TODO: should probably error for non-uints
@@ -94,37 +105,32 @@ func (o Object) GetMediaType(key string) (*mime.MediaType, error) {
 	}
 }
 
-/* https://www.w3.org/TR/activitystreams-core/#naturalLanguageValues */
-func (o Object) GetNatural(key string, language string) (string, error) {
-	values, err := o.GetObject(key + "Map")
-	hasMap := true
-	if errors.Is(err, ErrKeyNotPresent) {
-		hasMap = false
-	} else if err != nil {
-		return "", err
-	}
+type Markup interface {
+	Render(width int) string
+}
 
-	if hasMap {
-		if value, err := values.GetString(language); err == nil {
-			return value, nil
-		} else if !errors.Is(err, ErrKeyNotPresent) {
-			return "", fmt.Errorf("failed to extract from \"%s\": %w", key+"Map", err)
-		}
+func (o Object) GetMarkup(contentKey string, mediaTypeKey string) (Markup, []string, error) {
+	content, err := o.GetString(contentKey)
+	if err != nil {
+		return nil, nil, err
 	}
-
-	if value, err := o.GetString(key); err == nil {
-		return value, nil
-	} else if !errors.Is(err, ErrKeyNotPresent) {
-		return "", err
+	mediaType, err := o.GetMediaType(mediaTypeKey)
+	if errors.Is(err, ErrKeyNotPresent) {
+		mediaType = mime.Default()
+	} else if err != nil {
+		return nil, nil, err
 	}
 
-	if hasMap {
-		if value, err := values.GetString("und"); err == nil {
-			return value, nil
-		} else if !errors.Is(err, ErrKeyNotPresent) {
-			return "", fmt.Errorf("failed to extract from \"%s\": %w", key+"Map", err)
-		}
+	switch mediaType.Essence {
+	case "text/plain":
+		return plaintext.NewMarkup(content)
+	case "text/html":
+		return hypertext.NewMarkup(content)
+	case "text/gemini":
+		return gemtext.NewMarkup(content)
+	case "text/markdown":
+		return markdown.NewMarkup(content)
+	default:
+		return nil, nil, errors.New("cannot render text of mime type " + mediaType.Essence)
 	}
-
-	return "", fmt.Errorf("failed to extract natural \"%s\": %w", key, ErrKeyNotPresent)
 }

+ 0 - 57
object/object_test.go

@@ -3,7 +3,6 @@ package object
 import (
 	"errors"
 	"testing"
-	// "encoding/json"
 )
 
 func TestString(t *testing.T) {
@@ -108,59 +107,3 @@ func TestList(t *testing.T) {
 		t.Fatalf(`Expected ErrKeyNotPresent, not %v`, err)
 	}
 }
-
-func TestNatural(t *testing.T) {
-	// desired key should have value "target"
-	// language that is targeted is "en"
-	tests := []Object{
-		// TODO: this hasn't been implemented
-		// I will want it to be deterministic, so will need to sort by key and then take the first one
-		// // fall back to first element of map if nothing better
-		// {
-		// 	"contentMap": map[string]any {
-		// 		"fr": "target",
-		// 	},
-		// },
-
-		// use "und" if nothing better
-		{
-			"contentMap": map[string]any{
-				"und": "target",
-				"fr":  "ignored",
-			},
-		},
-
-		// use the key itself if nothing better
-		{
-			"content": "target",
-			"contentMap": map[string]any{
-				"und": "ignored",
-				"fr":  "ignored",
-			},
-		},
-
-		// use the desired language if possible
-		{
-			"content": "ignored",
-			"contentMap": map[string]any{
-				"en":  "target",
-				"und": "ignored",
-				"fr":  "ignored",
-			},
-		},
-
-		// use key itself if map is absent
-		{
-			"content": "target",
-		},
-	}
-	for i, test := range tests {
-		response, err := test.GetNatural("content", "en")
-		if err != nil {
-			t.Fatalf("Problem extracting natural in case %v: %v", i, err)
-		}
-		if response != "target" {
-			t.Fatalf(`Expected natural value in case %v to return "target", not %#v`, i, response)
-		}
-	}
-}

+ 22 - 3
plaintext/plaintext.go

@@ -7,7 +7,19 @@ import (
 	"strings"
 )
 
-func Render(text string, width int) (string, error) {
+type Markup string
+
+func NewMarkup(text string) (*Markup, []string, error) {
+	rendered, links := renderWithLinks(text, 80)
+	return (*Markup)(&rendered), links, nil
+}
+
+func (m Markup) Render(width int) string {
+	rendered, _ := renderWithLinks(string(m), width)
+	return rendered
+}
+
+func renderWithLinks(text string, width int) (string, []string) {
 	/*
 		Oversimplistic URL regexp based on RFC 3986, Appendix A
 		It matches:
@@ -18,8 +30,15 @@ func Render(text string, width int) (string, error) {
 				A-Z a-z 0-9 - . ? # / @ : [ ] % _ ~ ! $ & ' ( ) * + , ; =
 	*/
 
+	links := []string{}
+
 	url := regexp.MustCompile(`[A-Za-z][A-Za-z0-9+\-.]*://[A-Za-z0-9.?#/@:%_~!$&'()*+,;=\[\]\-]+`)
-	rendered := url.ReplaceAllStringFunc(text, style.Link)
+	rendered := url.ReplaceAllStringFunc(text, func(link string) string {
+		links = append(links, link)
+
+		// TODO: this will be superscripted
+		return style.Link(link, len(links))
+	})
 	wrapped := ansi.Wrap(rendered, width)
-	return strings.TrimSpace(wrapped), nil
+	return strings.Trim(wrapped, "\n"), links
 }

+ 20 - 8
plaintext/plaintext_test.go

@@ -3,22 +3,34 @@ package plaintext
 import (
 	"mimicry/ansi"
 	"mimicry/style"
-	"mimicry/util"
 	"testing"
 )
 
 func TestBasic(t *testing.T) {
 	input := `Yes, Jim, I found it under "http://www.w3.org/Addressing/",
-but you can probably pick it up from <ftp://foo.example.com/rfc/>.
+but you can probably pick it up from the store.
 Note the warning in <http://www.ics.uci.edu/pub/ietf/uri/historical.html#WARNING>.`
-	output, err := Render(input, 50)
+	markup, links, err := NewMarkup(input)
 	if err != nil {
-		panic(err)
+		t.Fatal(err)
 	}
+	output := markup.Render(50)
 
-	expected := ansi.Wrap("Yes, Jim, I found it under \""+style.Link("http://www.w3.org/Addressing/")+
-		"\",\nbut you can probably pick it up from <"+style.Link("ftp://foo.example.com/rfc/")+
-		">.\nNote the warning in <"+style.Link("http://www.ics.uci.edu/pub/ietf/uri/historical.html#WARNING")+">.", 50)
+	first := links[0]
+	if first != "http://www.w3.org/Addressing/" {
+		t.Fatalf("first uri should be http://www.w3.org/Addressing/ not %s", first)
+	}
+
+	second := links[1]
+	if second != "http://www.ics.uci.edu/pub/ietf/uri/historical.html#WARNING" {
+		t.Fatalf("first uri should be http://www.ics.uci.edu/pub/ietf/uri/historical.html#WARNING not %s", second)
+	}
 
-	util.AssertEqual(expected, output, t)
+	expected := ansi.Wrap("Yes, Jim, I found it under \""+style.Link("http://www.w3.org/Addressing/", 1)+
+		"\",\nbut you can probably pick it up from the store.\n" +
+		"Note the warning in <"+style.Link("http://www.ics.uci.edu/pub/ietf/uri/historical.html#WARNING", 2)+">.", 50)
+
+	if expected != output {
+		t.Fatalf("expected markup to be %s not %s", expected, output)
+	}
 }

+ 6 - 5
pub/activity.go

@@ -11,6 +11,7 @@ import (
 	"net/url"
 	"sync"
 	"time"
+	"mimicry/mime"
 )
 
 type Activity struct {
@@ -115,11 +116,7 @@ func (a *Activity) Parents(quantity uint) ([]Tangible, Tangible) {
 
 func (a *Activity) Timestamp() time.Time {
 	if errors.Is(a.createdErr, object.ErrKeyNotPresent) {
-		if a.kind == "Create" {
-			return a.target.Timestamp()
-		} else {
-			return time.Time{}
-		}
+		return a.target.Timestamp()
 	} else if a.createdErr != nil {
 		return time.Time{}
 	}
@@ -140,3 +137,7 @@ func (a *Activity) Actor() Tangible {
 func (a *Activity) Target() Tangible {
 	return a.target
 }
+
+func (a *Activity) SelectLink(input int) (string, *mime.MediaType, bool) {
+	return a.target.SelectLink(input)
+}

+ 33 - 27
pub/actor.go

@@ -6,13 +6,12 @@ import (
 	"golang.org/x/exp/slices"
 	"mimicry/ansi"
 	"mimicry/client"
-	"mimicry/mime"
 	"mimicry/object"
-	"mimicry/render"
 	"mimicry/style"
 	"net/url"
 	"strings"
 	"time"
+	"mimicry/mime"
 )
 
 type Actor struct {
@@ -24,10 +23,9 @@ type Actor struct {
 
 	id *url.URL
 
-	bio          string
+	bio          object.Markup
+	bioLinks	[]string
 	bioErr       error
-	mediaType    *mime.MediaType
-	mediaTypeErr error
 
 	joined    time.Time
 	joinedErr error
@@ -63,13 +61,9 @@ func NewActorFromObject(o object.Object, id *url.URL) (*Actor, error) {
 		return nil, fmt.Errorf("%w: %s is not an Actor", ErrWrongType, a.kind)
 	}
 
-	a.name, a.nameErr = o.GetNatural("name", "en")
+	a.name, a.nameErr = o.GetString("name")
 	a.handle, a.handleErr = o.GetString("preferredUsername")
-	a.bio, a.bioErr = o.GetNatural("summary", "en")
-	if a.bio == "" {
-		a.bioErr = object.ErrKeyNotPresent
-	}
-	a.mediaType, a.mediaTypeErr = o.GetMediaType("mediaType")
+	a.bio, a.bioLinks, a.bioErr = o.GetMarkup("summary", "mediaType")
 	a.joined, a.joinedErr = o.GetTime("published")
 
 	a.pfp, a.pfpErr = getBestLink(o, "icon", "image")
@@ -152,25 +146,13 @@ func (a *Actor) center(width int) (string, bool) {
 		return ansi.Wrap(style.Problem(a.bioErr), width), true
 	}
 
-	mediaType := a.mediaType
-	if errors.Is(a.mediaTypeErr, object.ErrKeyNotPresent) {
-		mediaType = mime.Default()
-	} else if a.mediaTypeErr != nil {
-		return ansi.Wrap(style.Problem(a.mediaTypeErr), width), true
-	}
-
-	rendered, err := render.Render(a.bio, mediaType.Essence, width)
-	if err != nil {
-		return style.Problem(err), true
-	}
+	rendered := a.bio.Render(width)
 	return rendered, true
 }
 
 func (a *Actor) footer(width int) (string, bool) {
-	if errors.Is(a.postsErr, object.ErrKeyNotPresent) {
+	if a.postsErr != nil {
 		return style.Problem(a.postsErr), true
-	} else if a.postsErr != nil {
-		return "", false
 	} else if quantity, err := a.posts.Size(); errors.Is(err, object.ErrKeyNotPresent) {
 		return "", false
 	} else if err != nil {
@@ -186,11 +168,11 @@ func (a *Actor) String(width int) string {
 	output := a.header(width)
 
 	if body, present := a.center(width - 4); present {
-		output += "\n\n" + ansi.Indent(body, "  ", true) + "\n"
+		output += "\n\n" + ansi.Indent(body, "  ", true)
 	}
 
 	if footer, present := a.footer(width); present {
-		output += "\n" + footer
+		output += "\n\n" + footer
 	}
 
 	return output
@@ -218,3 +200,27 @@ func (a *Actor) Timestamp() time.Time {
 		return a.joined
 	}
 }
+
+func (a *Actor) Banner() (string, *mime.MediaType, bool) {
+	if a.bannerErr != nil {
+		return "", nil, false
+	}
+
+	return a.banner.SelectWithDefaultMediaType(mime.UnknownSubtype("image"))
+}
+
+func (a *Actor) ProfilePic() (string, *mime.MediaType, bool) {
+	if a.pfpErr != nil {
+		return "", nil, false
+	}
+
+	return a.pfp.SelectWithDefaultMediaType(mime.UnknownSubtype("image"))
+}
+
+func (a *Actor) SelectLink(input int) (string, *mime.MediaType, bool) {
+	input -= 1
+	if len(a.bioLinks) <= input {
+		return "", nil, false
+	}
+	return a.bioLinks[input], mime.Unknown(), true
+}

+ 14 - 4
pub/collection.go

@@ -41,13 +41,17 @@ type Collection struct {
 }
 
 func NewCollection(input any, source *url.URL) (*Collection, error) {
-	c := &Collection{}
-	var o object.Object
-	var err error
-	o, c.id, err = client.FetchUnknown(input, source)
+	o, id, err := client.FetchUnknown(input, source)
 	if err != nil {
 		return nil, err
 	}
+	return NewCollectionFromObject(o, id)
+}
+
+func NewCollectionFromObject(o object.Object, id *url.URL) (*Collection, error) {
+	c := &Collection{}
+	c.id = id
+	var err error
 	if c.kind, err = o.GetString("type"); err != nil {
 		return nil, err
 	}
@@ -99,6 +103,12 @@ func (c *Collection) Harvest(amount uint, startingPoint uint) ([]Tangible, Conta
 		length = uint(len(c.elements))
 	}
 
+	// TODO: need a mechanism to not infiniloop when page has no elements
+	// be advised Mastodon has an empty Collection followed by a potentially
+	// empty CollectionPage followed by the content
+	// This is what causes the "Killed" message.
+	// The solution will be to make this method iterative instead of recursive
+
 	// TODO: change to bool nextWillBeFetched in which case amount from this page is all
 	// and later on the variable is clear
 

+ 36 - 12
pub/common.go

@@ -112,26 +112,50 @@ func getActor(o object.Object, key string, source *url.URL) (*Actor, error) {
 }
 
 func NewTangible(input any, source *url.URL) Tangible {
-	var fetched Tangible
-	fetched, err := NewPost(input, source)
+	fetched := New(input, source)
+	if tangible, ok := fetched.(Tangible); ok {
+		return tangible
+	}
+	return NewFailure(errors.New("item is non-Tangible"))
+}
 
-	if errors.Is(err, ErrWrongType) {
-		fetched, err = NewActor(input, source)
+func New(input any, source *url.URL) any {
+	o, id, err := client.FetchUnknown(input, source)
+	if err != nil {
+		return NewFailure(err)
 	}
 
-	if errors.Is(err, ErrWrongType) {
-		fetched, err = NewActivity(input, source)
+	var result any
+
+	result, err = NewActorFromObject(o, id)
+	if err == nil {
+		return result
+	} else if !errors.Is(err, ErrWrongType) {
+		return NewFailure(err)
 	}
 
-	if errors.Is(err, ErrWrongType) {
+	result, err = NewPostFromObject(o, id)
+	if err == nil {
+		return result
+	} else if !errors.Is(err, ErrWrongType) {
 		return NewFailure(err)
 	}
 
-	if err != nil {
+	result, err = NewActivityFromObject(o, id)
+	if err == nil {
+		return result
+	} else if !errors.Is(err, ErrWrongType) {
 		return NewFailure(err)
 	}
 
-	return fetched
+	result, err = NewCollectionFromObject(o, id)
+	if err == nil {
+		return result
+	} else if !errors.Is(err, ErrWrongType) {
+		return NewFailure(err)
+	}
+
+	return NewFailure(errors.New("item is of unrecognized type"))
 }
 
 /*
@@ -147,7 +171,7 @@ func getLinksShorthand(o object.Object, key string) ([]*Link, error) {
 
 	for i, element := range list {
 		switch narrowed := element.(type) {
-		case object.Object:
+		case map[string]any:
 			link, err := NewLink(narrowed)
 			if err != nil {
 				return nil, err
@@ -188,13 +212,13 @@ func getFirstLinkShorthand(o object.Object, key string) (*Link, error) {
 func getLinks(o object.Object, key string) ([]*Link, error) {
 	list, err := o.GetList(key)
 	if err != nil {
-		return nil, err
+		return []*Link{}, err
 	}
 	links := make([]*Link, len(list))
 	for i, element := range list {
 		link, err := NewLink(element)
 		if err != nil {
-			return nil, err
+			return []*Link{}, err
 		}
 		links[i] = link
 	}

+ 5 - 0
pub/failure.go

@@ -3,6 +3,7 @@ package pub
 import (
 	"mimicry/style"
 	"time"
+	"mimicry/mime"
 )
 
 type Failure struct {
@@ -41,3 +42,7 @@ func (f *Failure) Children() Container {
 func (f *Failure) Timestamp() time.Time {
 	return time.Time{}
 }
+
+func (f *Failure) SelectLink(input int) (string, *mime.MediaType, bool) {
+	return "", nil, false
+}

+ 2 - 0
pub/interfaces.go

@@ -2,6 +2,7 @@ package pub
 
 import (
 	"time"
+	"mimicry/mime"
 )
 
 type Any any
@@ -15,6 +16,7 @@ type Tangible interface {
 	Children() Container
 	Timestamp() time.Time
 	Name() string
+	SelectLink(input int) (string, *mime.MediaType, bool)
 }
 
 type Container interface {

+ 16 - 9
pub/link.go

@@ -59,10 +59,6 @@ func NewLink(input any) (*Link, error) {
 	return l, nil
 }
 
-func (l *Link) Kind() string {
-	return l.kind
-}
-
 func (l *Link) Alt() (string, error) {
 	if l.altErr == nil {
 		return l.alt, nil
@@ -96,13 +92,9 @@ func (l *Link) rating() (uint64, error) {
 	return height * width, nil
 }
 
-func (l *Link) MediaType() (*mime.MediaType, error) {
-	return l.mediaType, l.mediaTypeErr
-}
-
 func SelectBestLink(links []*Link, supertype string) (*Link, error) {
 	if len(links) == 0 {
-		return &Link{}, errors.New("can't select best link of type " + supertype + "/* from an empty list")
+		return nil, errors.New("can't select best link of type " + supertype + "/* from an empty list")
 	}
 
 	bestLink := links[0]
@@ -152,6 +144,21 @@ func SelectBestLink(links []*Link, supertype string) (*Link, error) {
 	return bestLink, nil
 }
 
+func (l *Link) Select() (string, *mime.MediaType, bool) {
+	return l.SelectWithDefaultMediaType(mime.Unknown())
+}
+
+func (l *Link) SelectWithDefaultMediaType(defaultMediaType *mime.MediaType) (string, *mime.MediaType, bool) {
+	if l.uriErr != nil {
+		return "", nil, false
+	}
+	/* I suppress this error here because it is shown in the alt text */
+	if l.mediaTypeErr != nil {
+		return l.uri.String(), defaultMediaType, true
+	}
+	return l.uri.String(), l.mediaType, true
+}
+
 func SelectFirstLink(links []*Link) (*Link, error) {
 	if len(links) == 0 {
 		return &Link{}, errors.New("can't select first Link from an empty list of links")

+ 48 - 33
pub/post.go

@@ -6,14 +6,13 @@ import (
 	"golang.org/x/exp/slices"
 	"mimicry/ansi"
 	"mimicry/client"
-	"mimicry/mime"
 	"mimicry/object"
-	"mimicry/render"
 	"mimicry/style"
 	"net/url"
 	"strings"
 	"sync"
 	"time"
+	"mimicry/mime"
 )
 
 type Post struct {
@@ -22,12 +21,11 @@ type Post struct {
 
 	title        string
 	titleErr     error
-	body         string
+	body         object.Markup
+	bodyLinks	[]string
 	bodyErr      error
-	mediaType    *mime.MediaType
-	mediaTypeErr error
-	link         *Link
-	linkErr      error
+	media         *Link
+	mediaErr      error
 	created      time.Time
 	createdErr   error
 	edited       time.Time
@@ -62,24 +60,22 @@ func NewPostFromObject(o object.Object, id *url.URL) (*Post, error) {
 		return nil, err
 	}
 
-	// TODO: for Lemmy, may have to auto-unwrap Create into a Post
 	if !slices.Contains([]string{
 		"Article", "Audio", "Document", "Image", "Note", "Page", "Video",
 	}, p.kind) {
 		return nil, fmt.Errorf("%w: %s is not a Post", ErrWrongType, p.kind)
 	}
 
-	p.title, p.titleErr = o.GetNatural("name", "en")
-	p.body, p.bodyErr = o.GetNatural("content", "en")
-	p.mediaType, p.mediaTypeErr = o.GetMediaType("mediaType")
+	p.title, p.titleErr = o.GetString("name")
+	p.body, p.bodyLinks, p.bodyErr = o.GetMarkup("content", "mediaType")
 	p.created, p.createdErr = o.GetTime("published")
 	p.edited, p.editedErr = o.GetTime("updated")
 	p.parent, p.parentErr = o.GetAny("inReplyTo")
 
 	if p.kind == "Image" || p.kind == "Audio" || p.kind == "Video" {
-		p.link, p.linkErr = getBestLinkShorthand(o, "url", strings.ToLower(p.kind))
+		p.media, p.mediaErr = getBestLinkShorthand(o, "url", strings.ToLower(p.kind))
 	} else {
-		p.link, p.linkErr = getFirstLinkShorthand(o, "url")
+		p.media, p.mediaErr = getFirstLinkShorthand(o, "url")
 	}
 
 	var wg sync.WaitGroup
@@ -185,17 +181,7 @@ func (p *Post) center(width int) (string, bool) {
 		return ansi.Wrap(style.Problem(p.bodyErr), width), true
 	}
 
-	mediaType := p.mediaType
-	if errors.Is(p.mediaTypeErr, object.ErrKeyNotPresent) {
-		mediaType = mime.Default()
-	} else if p.mediaTypeErr != nil {
-		return ansi.Wrap(style.Problem(p.mediaTypeErr), width), true
-	}
-
-	rendered, err := render.Render(p.body, mediaType.Essence, width)
-	if err != nil {
-		return style.Problem(err), true
-	}
+	rendered := p.body.Render(width)
 	return rendered, true
 }
 
@@ -210,8 +196,9 @@ func (p *Post) supplement(width int) (string, bool) {
 		return "", false
 	}
 
+	// TODO: don't think this is good, rework it
 	output := ""
-	for _, attachment := range p.attachments {
+	for i, attachment := range p.attachments {
 		if output != "" {
 			output += "\n"
 		}
@@ -220,9 +207,9 @@ func (p *Post) supplement(width int) (string, bool) {
 			output += style.Problem(err)
 			continue
 		}
-		output += style.LinkBlock(alt)
+		output += style.LinkBlock(ansi.Wrap(alt, width-2), len(p.bodyLinks) + i + 1)
 	}
-	return ansi.Wrap(output, width), true
+	return output, true
 }
 
 func (p *Post) footer(width int) string {
@@ -260,15 +247,19 @@ func (p Post) String(width int) string {
 func (p *Post) Preview(width int) string {
 	output := p.header(width)
 
-	if body, present := p.center(width); present {
-		if attachments, present := p.supplement(width); present {
-			output += "\n" + ansi.Snip(body+"\n"+attachments, width, 4, style.Color("\u2026"))
-		} else {
-			output += "\n" + ansi.Snip(body, width, 4, style.Color("\u2026"))
+	body, bodyPresent := p.center(width)
+	if bodyPresent {
+		output += "\n" + body
+	}
+
+	if attachments, present := p.supplement(width); present {
+		if bodyPresent {
+			output += "\n"
 		}
+		output += "\n" + attachments
 	}
 
-	return output
+	return ansi.Snip(output, width, 4, style.Color("\u2026"))
 }
 
 func (p *Post) Timestamp() time.Time {
@@ -293,3 +284,27 @@ func (p *Post) Creators() []Tangible {
 func (p *Post) Recipients() []Tangible {
 	return p.recipients
 }
+
+func (p *Post) Media() (string, *mime.MediaType, bool) {
+	if p.mediaErr != nil {
+		return "", nil, false
+	}
+
+	if p.kind == "Audio" || p.kind == "Video" || p.kind == "Image" {
+		return p.media.SelectWithDefaultMediaType(mime.UnknownSubtype(strings.ToLower(p.kind)))
+	}
+
+	return p.media.Select()
+}
+
+func (p *Post) SelectLink(input int) (string, *mime.MediaType, bool) {
+	input -= 1
+	if len(p.bodyLinks) > input {
+		return p.bodyLinks[input], mime.Unknown(), true
+	}
+	nextIndex := input - len(p.bodyLinks)
+	if len(p.attachments) > nextIndex {
+		return p.attachments[nextIndex].Select()
+	}
+	return "", nil, false
+}

+ 3 - 3
pub/user-input.go

@@ -11,7 +11,7 @@ func FetchUserInput(text string) Any {
 		if err != nil {
 			return NewFailure(err)
 		}
-		return NewTangible(link, nil)
+		return New(link, nil)
 	}
 
 	if strings.HasPrefix(text, "/") ||
@@ -21,8 +21,8 @@ func FetchUserInput(text string) Any {
 		if err != nil {
 			return NewFailure(err)
 		}
-		return NewTangible(object, nil)
+		return New(object, nil)
 	}
 
-	return NewTangible(text, nil)
+	return New(text, nil)
 }

+ 0 - 41
render/render.go

@@ -1,41 +0,0 @@
-package render
-
-import (
-	"errors"
-	"mimicry/gemtext"
-	"mimicry/hypertext"
-	"mimicry/markdown"
-	"mimicry/plaintext"
-	"strings"
-	"unicode"
-)
-
-// TODO: perhaps `dropControlCharacters` should happen to all
-//
-//	`getNatural` strings when they are pulled from the JSON
-//
-// TODO: need to add a width parameter to all of this
-func Render(text string, mediaType string, width int) (string, error) {
-	text = strings.Map(dropControlCharacters, text)
-
-	switch {
-	case mediaType == "text/plain":
-		return plaintext.Render(text, width)
-	case mediaType == "text/html":
-		return hypertext.Render(text, width)
-	case mediaType == "text/gemini":
-		return gemtext.Render(text, width)
-	case mediaType == "text/markdown":
-		return markdown.Render(text, width)
-	default:
-		return "", errors.New("cannot render text of mime type " + mediaType)
-	}
-}
-
-func dropControlCharacters(character rune) rune {
-	if unicode.IsControl(character) && character != '\t' && character != '\n' {
-		return -1 // drop the character
-	}
-
-	return character
-}

+ 0 - 20
render/render_test.go

@@ -1,20 +0,0 @@
-package render
-
-import (
-	"mimicry/style"
-	"mimicry/util"
-	"testing"
-)
-
-func TestControlCharacterEscapes(t *testing.T) {
-	input := "Yes, \u0000Jim, I\nfound it\tunder \u001Bhttp://www.w3.org/Addressing/"
-	output, err := Render(input, "text/plain", 50)
-	if err != nil {
-		panic(err)
-	}
-
-	expected := "Yes, Jim, I\nfound it\tunder " +
-		style.Link("http://www.w3.org/Addressing/")
-
-	util.AssertEqual(expected, output, t)
-}

+ 31 - 6
style/style.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"mimicry/ansi"
 	"strings"
+	"strconv"
 )
 
 func background(text string, r uint8, g uint8, b uint8) string {
@@ -44,12 +45,16 @@ func Color(text string) string {
 	return foreground(text, 164, 245, 155)
 }
 
-func Problem(text error) string {
-	return foreground(text.Error(), 156, 53, 53)
+func Problem(issue error) string {
+	return Red(issue.Error())
 }
 
-func Link(text string) string {
-	return Underline(Color(text))
+func Red(text string) string {
+	return foreground(text, 156, 53, 53)
+}
+
+func Link(text string, number int) string {
+	return Color(Underline(text) + superscript(number))
 }
 
 func CodeBlock(text string) string {
@@ -61,8 +66,8 @@ func QuoteBlock(text string) string {
 	return Color(prefixed)
 }
 
-func LinkBlock(text string) string {
-	return "‣ " + ansi.Indent(Link(text), "  ", false)
+func LinkBlock(text string, number int) string {
+	return "‣ " + ansi.Indent(Link(text, number), "  ", false)
 }
 
 func Header(text string, level uint) string {
@@ -74,3 +79,23 @@ func Header(text string, level uint) string {
 func Bullet(text string) string {
 	return "• " + ansi.Indent(text, "  ", false)
 }
+
+func superscript(value int) string {
+	text := strconv.Itoa(value)
+	return strings.Map(func(input rune) rune {
+		switch input {
+		case '0': return '\u2070'
+		case '1': return '\u00B9'
+		case '2': return '\u00B2'
+		case '3': return '\u00B3'
+		case '4': return '\u2074'
+		case '5': return '\u2075'
+		case '6': return '\u2076'
+		case '7': return '\u2077'
+		case '8': return '\u2078'
+		case '9': return '\u2079'
+		default:
+			panic("cannot superscript non-digit")
+		}
+	}, text)
+}

+ 276 - 26
ui/ui.go

@@ -10,6 +10,10 @@ import (
 	"mimicry/style"
 	"sync"
 	"mimicry/history"
+	"os/exec"
+	"mimicry/mime"
+	"strings"
+	"strconv"
 )
 
 /*
@@ -17,6 +21,22 @@ import (
 	are not and need to be protected by State.m
 */
 
+/* Modes */
+const (
+	loading = iota
+	normal
+	command
+	selection
+	opening
+	problem
+)
+
+const (
+	enterKey byte = '\r'
+	escapeKey byte = 27
+	backspaceKey byte = 127
+)
+
 type Page struct {
 	feed  *feed.Feed
 	index int
@@ -39,10 +59,13 @@ type State struct {
 	output func(string)
 
 	config *config.Config
+
+	mode int
+	buffer string
 }
 
 func (s *State) view() string {
-	if s.h.IsEmpty() || s.h.Current().feed.IsEmpty() {
+	if s.mode == loading {
 		return ansi.CenterVertically("", style.Color("  Loading…"), "", uint(s.height))
 	}
 
@@ -85,13 +108,127 @@ func (s *State) view() string {
 		}
 		bottom += "\n  " + style.Color("Loading…")
 	}
-	return ansi.CenterVertically(top, center, bottom, uint(s.height))
+	output := ansi.CenterVertically(top, center, bottom, uint(s.height))
+	
+	var footer string
+	switch s.mode {
+	case normal:
+		break
+	case selection:
+		footer = "Selecting " + s.buffer + " (press . to open internally, enter to open externally)"
+	case command:
+		footer = ":" + s.buffer
+	case opening:
+		footer = "Opening " + s.buffer + "\u2026"
+	case problem:
+		footer = s.buffer
+	default:
+		panic("encountered unrecognized mode")
+	}
+	if footer != "" {
+		output = ansi.ReplaceLastLine(output, style.Highlight(ansi.SetLength(footer, s.width, "\u2026")))
+	}
+
+	return output
 }
 
 func (s *State) Update(input byte) {
 	s.m.Lock()
 	defer s.m.Unlock()
+
+	if s.mode == loading {
+		panic("inputs should not come through while loading, as all loading functions block the UI thread")
+	}
+
+	if input == escapeKey {
+		s.buffer = ""
+		s.mode = normal
+		s.output(s.view())
+		return
+	}
+
+	if input == backspaceKey {
+		if len(s.buffer) == 0 {
+			s.mode = normal
+			s.output(s.view())
+			return
+		}
+		s.buffer = s.buffer[:len(s.buffer)-1]
+		if s.buffer == "" && s.mode == selection {
+			s.mode = normal
+		}
+		s.output(s.view())
+		return
+	}
+
+	if s.mode == command {
+		if input == enterKey {
+			if args := strings.SplitN(s.buffer, " ", 2); len(args) == 2 {
+				err := s.subcommand(args[0], args[1])
+				if err != nil {
+					s.buffer = "Failed to run command: " + ansi.Squash(err.Error())
+					s.mode = problem
+					s.output(s.view())
+					s.buffer = ""
+					s.mode = normal
+				}
+			} else {
+				s.buffer = ""
+				s.mode = normal
+			}
+			return
+		}
+		s.buffer += string(input)
+		s.output(s.view())
+		return
+	}
+
+	if input == ':' {
+		s.buffer = ""
+		s.mode = command
+		s.output(s.view())
+		return
+	}
+
+	if input >= '0' && input <= '9' {
+		if s.mode != selection {
+			s.buffer = ""
+		}
+		s.buffer += string(input)
+		s.mode = selection
+		s.output(s.view())
+		return
+	}
+
+	if s.mode == selection {
+		if input == '.' || input == enterKey {
+			number, err := strconv.Atoi(s.buffer)
+			if err != nil {
+				panic("buffer had a non-number while in selection mode")
+			}
+			link, mediaType, present := s.h.Current().feed.Get(s.h.Current().index).SelectLink(number)
+			if !present {
+				s.buffer = ""
+				s.mode = normal
+				s.output(s.view())
+				return
+			}
+			if input == '.' {
+				s.openInternally(link)
+			}
+			if input == enterKey {
+				s.openExternally(link, mediaType)
+			}
+			return
+		}
+		/* At this point we know input is a non-number, non-., non-enter */
+		s.mode = normal
+		s.buffer = ""
+	}
+
+	/* At this point we know we are in normal mode */
 	switch input {
+	// TODO: make feed stateful so all this logic is nicer. Functions will be MoveUp, MoveDown, MoveToCenter
 	case 'k': // up
 		if s.h.Current().feed.Contains(s.h.Current().index - 1) {
 			s.h.Current().index -= 1
@@ -135,6 +272,28 @@ func (s *State) Update(input byte) {
 			actor := activity.Actor()
 			s.switchTo(actor)
 		}
+	case 'o':
+		unwrapped := s.h.Current().feed.Get(s.h.Current().index)
+		if activity, ok := unwrapped.(*pub.Activity); ok {
+			unwrapped = activity.Target()
+		}
+		if post, ok := unwrapped.(*pub.Post); ok {
+			if link, mediaType, present := post.Media(); present {
+				s.openExternally(link, mediaType)
+			}
+		}
+	case 'p':
+		if actor, ok := s.h.Current().feed.Get(s.h.Current().index).(*pub.Actor); ok {
+			if link, mediaType, present := actor.ProfilePic(); present {
+				s.openExternally(link, mediaType)
+			}
+		}
+	case 'b':
+		if actor, ok := s.h.Current().feed.Get(s.h.Current().index).(*pub.Actor); ok {
+			if link, mediaType, present := actor.Banner(); present {
+				s.openExternally(link, mediaType)
+			}
+		}
 	}
 	s.output(s.view())
 }
@@ -164,14 +323,21 @@ func (s *State) switchTo(item any) {
 			frontier: narrowed,
 		})
 	case pub.Container:
+		s.mode = loading
+		s.buffer = ""
+		s.output(s.view())
+		children, nextCollection, newBasepoint := narrowed.Harvest(uint(s.config.Context), 0)
 		s.h.Add(&Page{
-			feed: feed.CreateEmpty(),
-			children: narrowed,
+			basepoint: newBasepoint,
+			children: nextCollection,
+			feed: feed.CreateAndAppend(children),
 			index: 1,
 		})
 	default:
 		panic(fmt.Sprintf("unrecognized non-Tangible non-Container: %T", item))
 	}
+	s.mode = normal
+	s.buffer = ""
 	s.loadSurroundings()
 }
 
@@ -217,31 +383,40 @@ func (s *State) loadSurroundings() {
 	}
 }
 
-func (s *State) Open(input string) {
-	go func() {
-		s.m.Lock()
-		s.output(s.view())
-		s.m.Unlock()
-		result := pub.FetchUserInput(input)
-		s.m.Lock()
-		s.switchTo(result)
-		s.output(s.view())
-		s.m.Unlock()
-	}()
+func (s *State) openUserInput(input string) {
+	s.mode = loading
+	s.buffer = ""
+	s.output(s.view())
+	result := pub.FetchUserInput(input)
+	s.switchTo(result)
+	s.output(s.view())
 }
 
-func (s *State) Feed(input string) {
-	go func() {
-		s.m.Lock()
-		s.output(s.view())
-		inputs := s.config.Feeds[input]
-		s.m.Unlock()
-		result := splicer.NewSplicer(inputs)
-		s.m.Lock()
-		s.switchTo(result)
+func (s *State) openInternally(input string) {
+	s.mode = loading
+	s.buffer = ""
+	s.output(s.view())
+	result := pub.New(input, nil)
+	s.switchTo(result)
+	s.output(s.view())
+}
+
+func (s *State) openFeed(input string) {
+	inputs, present := s.config.Feeds[input]
+	if !present {
+		s.mode = problem
+		s.buffer = "Failed to open feed: " + input + " is not a known feed"
 		s.output(s.view())
-		s.m.Unlock()
-	}()
+		s.mode = normal
+		s.buffer = ""
+		return
+	}
+	s.mode = loading
+	s.buffer = ""
+	s.output(s.view())
+	result := splicer.NewSplicer(inputs)
+	s.switchTo(result)
+	s.output(s.view())
 }
 
 func NewState(config *config.Config, width int, height int, output func(string)) *State {
@@ -252,6 +427,81 @@ func NewState(config *config.Config, width int, height int, output func(string))
 		height: height,
 		output: output,
 		m:      &sync.Mutex{},
+		mode:   loading,
 	}
 	return s
 }
+
+func (s *State) Subcommand(name, argument string) error {
+	s.m.Lock()
+	defer s.m.Unlock()
+	return s.subcommand(name, argument)
+}
+
+func (s *State) subcommand(name, argument string) error {
+	switch name {
+	case "open":
+		s.openUserInput(argument)
+	case "feed":
+		s.openFeed(argument)
+	default:
+		return fmt.Errorf("unrecognized subcommand: %s", name)
+	}
+	return nil
+}
+
+func (s *State) openExternally(link string, mediaType *mime.MediaType) {
+	s.mode = opening
+	s.buffer = link
+	s.output(s.view())
+
+	command := make([]string, len(s.config.MediaHook))
+	copy(command, s.config.MediaHook)
+
+	foundPercentU := false
+	for i, field := range command {
+		if i == 0 {
+			continue
+		}
+		switch field {
+		case "%u":
+			command[i] = link
+			foundPercentU = true
+		case "%m":
+			command[i] = mediaType.Essence
+		case "%s":
+			command[i] = mediaType.Subtype
+		case "%t":
+			command[i] = mediaType.Supertype
+		}
+	}
+
+	cmd := exec.Command(command[0], command[1:]...)
+	if !foundPercentU {
+		cmd.Stdin = strings.NewReader(link)
+	}
+
+	go func() {
+		err := cmd.Run()
+
+		s.m.Lock()
+		defer s.m.Unlock()
+		
+		if s.mode != opening {
+			return
+		}
+
+		if err != nil {
+			s.mode = problem
+			s.buffer = "Failed to open link: " + ansi.Squash(err.Error())
+			s.output(s.view())
+			s.mode = normal
+			s.buffer = ""
+			return
+		}
+	
+		s.mode = normal
+		s.buffer = ""
+		s.output(s.view())
+	}()
+}

+ 1 - 0
util/util.go

@@ -4,6 +4,7 @@ import (
 	"testing"
 )
 
+// TODO: delete this function
 func AssertEqual(expected string, output string, t *testing.T) {
 	if expected != output {
 		t.Fatalf("Expected `%s` not `%s`\n", expected, output)