Lagrange [release]

Added option to load image instead of scrolling

0b2b40a233c014e684f6efed0298efda02e7abf4
diff --git a/res/about/version.gmi b/res/about/version.gmi
index 3679a465..f3d40e76 100644
--- a/res/about/version.gmi
+++ b/res/about/version.gmi
@@ -7,6 +7,7 @@
 # Release notes
 
 ## 0.10
+* Added option to load inline images when pressing Space or ↓. If an image link is visible, the image will be loaded instead of scrolling. This option is disabled by default.
 * Added an option to use a proxy server for Gemini requests.
 * Added a keybinding to activate keyboard link navigation mode (default is "F").
 * Clearing and resetting keybindings via a context menu.
diff --git a/src/app.c b/src/app.c
index f2741ee6..b53666c8 100644
--- a/src/app.c
+++ b/src/app.c
@@ -183,6 +183,7 @@ static iString *serializePrefs_App_(const iApp *d) {
     appendFormat_String(str, "prefs.mono.gopher.changed arg:%d\n", d->prefs.monospaceGopher);
     appendFormat_String(str, "zoom.set arg:%d\n", d->prefs.zoomPercent);
     appendFormat_String(str, "smoothscroll arg:%d\n", d->prefs.smoothScrolling);
+    appendFormat_String(str, "imageloadscroll arg:%d\n", d->prefs.loadImageInsteadOfScrolling);
     appendFormat_String(str, "linewidth.set arg:%d\n", d->prefs.lineWidth);
     appendFormat_String(str, "prefs.biglede.changed arg:%d\n", d->prefs.bigFirstParagraph);
     appendFormat_String(str, "prefs.sideicon.changed arg:%d\n", d->prefs.sideIcon);
@@ -746,6 +747,8 @@ static iBool handlePrefsCommands_(iWidget *d, const char *cmd) {
                          isSelected_Widget(findChild_Widget(d, "prefs.retainwindow")));
         postCommandf_App("smoothscroll arg:%d",
                          isSelected_Widget(findChild_Widget(d, "prefs.smoothscroll")));
+        postCommandf_App("imageloadscroll arg:%d",
+                         isSelected_Widget(findChild_Widget(d, "prefs.imageloadscroll")));
         postCommandf_App("ostheme arg:%d",
                          isSelected_Widget(findChild_Widget(d, "prefs.ostheme")));
         postCommandf_App("proxy.gemini address:%s",
@@ -956,6 +959,10 @@ iBool handleCommand_App(const char *cmd) {
         d->prefs.smoothScrolling = arg_Command(cmd);
         return iTrue;
     }
+    else if (equal_Command(cmd, "imageloadscroll")) {
+        d->prefs.loadImageInsteadOfScrolling = arg_Command(cmd);
+        return iTrue;
+    }
     else if (equal_Command(cmd, "forcewrap.toggle")) {
         d->prefs.forceLineWrap = !d->prefs.forceLineWrap;
         updateSize_DocumentWidget(document_App());
@@ -1153,6 +1160,7 @@ iBool handleCommand_App(const char *cmd) {
         setText_InputWidget(findChild_Widget(dlg, "prefs.downloads"), &d->prefs.downloadDir);
         setToggle_Widget(findChild_Widget(dlg, "prefs.hoveroutline"), d->prefs.hoverOutline);
         setToggle_Widget(findChild_Widget(dlg, "prefs.smoothscroll"), d->prefs.smoothScrolling);
+        setToggle_Widget(findChild_Widget(dlg, "prefs.imageloadscroll"), d->prefs.loadImageInsteadOfScrolling);
         setToggle_Widget(findChild_Widget(dlg, "prefs.ostheme"), d->prefs.useSystemTheme);
         setToggle_Widget(findChild_Widget(dlg, "prefs.retainwindow"), d->prefs.retainWindowSize);
         setText_InputWidget(findChild_Widget(dlg, "prefs.uiscale"),
diff --git a/src/gmdocument.c b/src/gmdocument.c
index 923c20c9..62a0ba55 100644
--- a/src/gmdocument.c
+++ b/src/gmdocument.c
@@ -1366,8 +1366,14 @@ enum iColorId linkColor_GmDocument(const iGmDocument *d, iGmLinkId linkId, enum
 }
 
 iBool isMediaLink_GmDocument(const iGmDocument *d, iGmLinkId linkId) {
-    return (linkFlags_GmDocument(d, linkId) &
-            (imageFileExtension_GmLinkFlag | audioFileExtension_GmLinkFlag)) != 0;
+    const iString *dstUrl = absoluteUrl_String(&d->url, linkUrl_GmDocument(d, linkId));
+    const iRangecc scheme = urlScheme_String(dstUrl);
+    if (equalCase_Rangecc(scheme, "gemini") || equalCase_Rangecc(scheme, "gopher") ||
+        willUseProxy_App(scheme)) {
+        return (linkFlags_GmDocument(d, linkId) &
+                (imageFileExtension_GmLinkFlag | audioFileExtension_GmLinkFlag)) != 0;
+    }
+    return iFalse;
 }
 
 const iString *title_GmDocument(const iGmDocument *d) {
diff --git a/src/prefs.c b/src/prefs.c
index 80b11c30..dc2bd601 100644
--- a/src/prefs.c
+++ b/src/prefs.c
@@ -24,21 +24,23 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
 
 void init_Prefs(iPrefs *d) {
     d->dialogTab         = 0;
-    d->theme             = dark_ColorTheme;
     d->useSystemTheme    = iTrue;
+    d->theme             = dark_ColorTheme;
     d->retainWindowSize  = iTrue;
+    d->uiScale           = 1.0f; /* default set elsewhere */
     d->zoomPercent       = 100;
+    d->sideIcon          = iTrue;
+    d->hoverOutline      = iFalse;
     d->smoothScrolling   = iTrue;
-    d->forceLineWrap     = iFalse;
-    d->quoteIcon         = iTrue;
+    d->loadImageInsteadOfScrolling = iFalse;
     d->font              = nunito_TextFont;
     d->headingFont       = nunito_TextFont;
     d->monospaceGemini   = iFalse;
     d->monospaceGopher   = iFalse;
     d->lineWidth         = 40;
     d->bigFirstParagraph = iTrue;
-    d->sideIcon          = iTrue;
-    d->hoverOutline      = iFalse;
+    d->forceLineWrap     = iFalse;
+    d->quoteIcon         = iTrue;
     d->docThemeDark      = colorfulDark_GmDocumentTheme;
     d->docThemeLight     = white_GmDocumentTheme;
     d->saturation        = 1.0f;
diff --git a/src/prefs.h b/src/prefs.h
index 33ce8b41..a3993629 100644
--- a/src/prefs.h
+++ b/src/prefs.h
@@ -33,31 +33,37 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
 iDeclareType(Prefs)
 
 struct Impl_Prefs {
-    int dialogTab;
-    iBool retainWindowSize;
-    float uiScale;
-    int zoomPercent;
-    iBool smoothScrolling;
-    iBool useSystemTheme;
+    /* UI state */
+    int              dialogTab;
+    /* Window */
+    iBool            useSystemTheme;
     enum iColorTheme theme;
-    iString geminiProxy;
-    iString gopherProxy;
-    iString httpProxy;
-    iString downloadDir;
-    /* Content */
-    enum iTextFont font;
-    enum iTextFont headingFont;
-    iBool monospaceGemini;
-    iBool monospaceGopher;
-    int lineWidth;
-    iBool bigFirstParagraph;
-    iBool forceLineWrap;
-    iBool quoteIcon;
-    iBool sideIcon;
-    iBool hoverOutline;
+    iBool            retainWindowSize;
+    float            uiScale;
+    int              zoomPercent;
+    iBool            sideIcon;
+    /* Behavior */
+    iString          downloadDir;
+    iBool            hoverOutline;
+    iBool            smoothScrolling;
+    iBool            loadImageInsteadOfScrolling;
+    /* Network */
+    iString          geminiProxy;
+    iString          gopherProxy;
+    iString          httpProxy;
+    /* Style */
+    enum iTextFont   font;
+    enum iTextFont   headingFont;
+    iBool            monospaceGemini;
+    iBool            monospaceGopher;
+    int              lineWidth;
+    iBool            bigFirstParagraph;
+    iBool            forceLineWrap;
+    iBool            quoteIcon;
+    /* Colors */
     enum iGmDocumentTheme docThemeDark;
     enum iGmDocumentTheme docThemeLight;
-    float saturation;
+    float                 saturation;
 };
 
 iDeclareTypeConstruction(Prefs)
diff --git a/src/ui/documentwidget.c b/src/ui/documentwidget.c
index 59f5a92c..0fc969ba 100644
--- a/src/ui/documentwidget.c
+++ b/src/ui/documentwidget.c
@@ -1151,10 +1151,8 @@ static iMediaRequest *findMediaRequest_DocumentWidget_(const iDocumentWidget *d,
 
 static iBool requestMedia_DocumentWidget_(iDocumentWidget *d, iGmLinkId linkId) {
     if (!findMediaRequest_DocumentWidget_(d, linkId)) {
-        pushBack_ObjectList(
-            d->media,
-            iClob(new_MediaRequest(
-                d, linkId, absoluteUrl_String(d->mod.url, linkUrl_GmDocument(d->doc, linkId)))));
+        const iString *imageUrl = absoluteUrl_String(d->mod.url, linkUrl_GmDocument(d->doc, linkId));
+        pushBack_ObjectList(d->media, iClob(new_MediaRequest(d, linkId, imageUrl)));
         invalidate_DocumentWidget_(d);
         return iTrue;
     }
@@ -1235,6 +1233,22 @@ static void allocVisBuffer_DocumentWidget_(const iDocumentWidget *d) {
     }
 }
 
+static iBool fetchNextUnfetchedImage_DocumentWidget_(iDocumentWidget *d) {
+    iConstForEach(PtrArray, i, &d->visibleLinks) {
+        const iGmRun *run = i.ptr;
+        if (run->linkId && !run->imageId && ~run->flags & decoration_GmRunFlag) {
+            const int linkFlags = linkFlags_GmDocument(d->doc, run->linkId);
+            if (isMediaLink_GmDocument(d->doc, run->linkId) &&
+                ~linkFlags & content_GmLinkFlag && ~linkFlags & permanent_GmLinkFlag ) {
+                if (requestMedia_DocumentWidget_(d, run->linkId)) {
+                    return iTrue;
+                }
+            }
+        }
+    }
+    return iFalse;
+}
+
 static iBool handleCommand_DocumentWidget_(iDocumentWidget *d, const char *cmd) {
     iWidget *w = as_Widget(d);
     if (equal_Command(cmd, "window.resized") || equal_Command(cmd, "font.changed")) {
@@ -1572,13 +1586,16 @@ static iBool handleCommand_DocumentWidget_(iDocumentWidget *d, const char *cmd)
         return iTrue;
     }
     else if (equal_Command(cmd, "scroll.page") && document_App() == d) {
-        if (argLabel_Command(cmd, "repeat")) {
-            /* TODO: Adjust scroll animation to be linear during repeated scroll? */
+        const int dir = arg_Command(cmd);
+        if (!argLabel_Command(cmd, "repeat") && prefs_App()->loadImageInsteadOfScrolling &&
+            dir > 0) {
+            if (fetchNextUnfetchedImage_DocumentWidget_(d)) {
+                return iTrue;
+            }
         }
         smoothScroll_DocumentWidget_(d,
-                                     arg_Command(cmd) *
-                                         (0.5f * height_Rect(documentBounds_DocumentWidget_(d)) -
-                                          0 * lineHeight_Text(paragraph_FontId)),
+                                     dir * (0.5f * height_Rect(documentBounds_DocumentWidget_(d)) -
+                                            0 * lineHeight_Text(paragraph_FontId)),
                                      smoothDuration_DocumentWidget_);
         return iTrue;
     }
@@ -1599,8 +1616,15 @@ static iBool handleCommand_DocumentWidget_(iDocumentWidget *d, const char *cmd)
         return iTrue;
     }
     else if (equal_Command(cmd, "scroll.step") && document_App() == d) {
+        const int dir = arg_Command(cmd);
+        if (!argLabel_Command(cmd, "repeat") && prefs_App()->loadImageInsteadOfScrolling &&
+            dir > 0) {
+            if (fetchNextUnfetchedImage_DocumentWidget_(d)) {
+                return iTrue;
+            }
+        }
         smoothScroll_DocumentWidget_(d,
-                                     3 * lineHeight_Text(paragraph_FontId) * arg_Command(cmd),
+                                     3 * lineHeight_Text(paragraph_FontId) * dir,
                                      smoothDuration_DocumentWidget_);
         return iTrue;
     }
diff --git a/src/ui/keys.c b/src/ui/keys.c
index 5b88bfcf..ea874343 100644
--- a/src/ui/keys.c
+++ b/src/ui/keys.c
@@ -56,25 +56,46 @@ static void clear_Keys_(iKeys *d) {
     }
 }
 
+enum iBindFlag {
+    argRepeat_BindFlag = iBit(1),
+};
+
 /* TODO: This indirection could be used for localization, although all UI strings
    would need to be similarly handled. */
-static const struct { int id; iMenuItem bind; } defaultBindings_[] = {
-    { 1,  { "Jump to top",               SDLK_HOME, 0,                  "scroll.top"         } },
-    { 2,  { "Jump to bottom",            SDLK_END, 0,                   "scroll.bottom"      } },
-    { 10, { "Scroll up",                 SDLK_UP, 0,                    "scroll.step arg:-1" } },
-    { 11, { "Scroll down",               SDLK_DOWN, 0,                  "scroll.step arg:1"  } },
-    { 20, { "Scroll up half a page",     SDLK_PAGEUP, 0,                "scroll.page arg:-1" } },
-    { 21, { "Scroll down half a page",   SDLK_PAGEDOWN, 0,              "scroll.page arg:1"  } },
-    { 30, { "Go back",                   navigateBack_KeyShortcut,      "navigate.back"      } },
-    { 31, { "Go forward",                navigateForward_KeyShortcut,   "navigate.forward"   } },
-    { 32, { "Go to parent directory",    navigateParent_KeyShortcut,    "navigate.parent"    } },
-    { 33, { "Go to site root",           navigateRoot_KeyShortcut,      "navigate.root"      } },
-    { 40, { "Open link via keyboard",    'f', 0,                        "document.linkkeys"} },
+static const struct { int id; iMenuItem bind; int flags; } defaultBindings_[] = {
+    { 1,  { "Jump to top",               SDLK_HOME, 0,                  "scroll.top"         }, 0 },
+    { 2,  { "Jump to bottom",            SDLK_END, 0,                   "scroll.bottom"      }, 0 },
+    { 10, { "Scroll up",                 SDLK_UP, 0,                    "scroll.step arg:-1" }, argRepeat_BindFlag },
+    { 11, { "Scroll down",               SDLK_DOWN, 0,                  "scroll.step arg:1"  }, argRepeat_BindFlag },
+    { 20, { "Scroll up half a page",     SDLK_PAGEUP, 0,                "scroll.page arg:-1" }, argRepeat_BindFlag },
+    { 21, { "Scroll down half a page",   SDLK_PAGEDOWN, 0,              "scroll.page arg:1"  }, argRepeat_BindFlag },
+    { 30, { "Go back",                   navigateBack_KeyShortcut,      "navigate.back"      }, 0 },
+    { 31, { "Go forward",                navigateForward_KeyShortcut,   "navigate.forward"   }, 0 },
+    { 32, { "Go to parent directory",    navigateParent_KeyShortcut,    "navigate.parent"    }, 0 },
+    { 33, { "Go to site root",           navigateRoot_KeyShortcut,      "navigate.root"      }, 0 },
+    { 40, { "Open link via keyboard",    'f', 0,                        "document.linkkeys"  }, 0 },
     /* The following cannot currently be changed (built-in duplicates). */
-    { 1000, { NULL, SDLK_SPACE, KMOD_SHIFT, "scroll.page arg:-1" } },
-    { 1001, { NULL, SDLK_SPACE, 0, "scroll.page arg:1" } },
+    { 1000, { NULL, SDLK_SPACE, KMOD_SHIFT, "scroll.page arg:-1" }, argRepeat_BindFlag },
+    { 1001, { NULL, SDLK_SPACE, 0, "scroll.page arg:1" }, argRepeat_BindFlag },
 };
 
+static iBinding *findId_Keys_(iKeys *d, int id) {
+    iForEach(Array, i, &d->bindings) {
+        iBinding *bind = i.value;
+        if (bind->id == id) {
+            return bind;
+        }
+    }
+    return NULL;
+}
+
+static void setFlags_Keys_(int id, int bindFlags) {
+    iBinding *bind = findId_Keys_(&keys_, id);
+    if (bind) {
+        bind->flags = bindFlags;
+    }
+}
+
 static void bindDefaults_(void) {
     iForIndices(i, defaultBindings_) {
         const int       id   = defaultBindings_[i].id;
@@ -83,6 +104,7 @@ static void bindDefaults_(void) {
         if (bind.label) {
             setLabel_Keys(id, bind.label);
         }
+        setFlags_Keys_(id, defaultBindings_[i].flags);
     }
 }
 
@@ -95,16 +117,6 @@ static iBinding *find_Keys_(iKeys *d, int key, int mods) {
     return NULL;
 }
 
-static iBinding *findId_Keys_(iKeys *d, int id) {
-    iForEach(Array, i, &d->bindings) {
-        iBinding *bind = i.value;
-        if (bind->id == id) {
-            return bind;
-        }
-    }
-    return NULL;
-}
-
 static iBinding *findCommand_Keys_(iKeys *d, const char *command) {
     /* Note: O(n) */
     iForEach(Array, i, &d->bindings) {
@@ -259,7 +271,12 @@ iBool processEvent_Keys(const SDL_Event *ev) {
     if (ev->type == SDL_KEYDOWN) {
         const iBinding *bind = find_Keys_(d, ev->key.keysym.sym, keyMods_Sym(ev->key.keysym.mod));
         if (bind) {
-            postCommandString_App(&bind->command);
+            if (ev->key.repeat && (bind->flags & argRepeat_BindFlag)) {
+                postCommandf_App("%s repeat:1", cstr_String(&bind->command));
+            }
+            else {
+                postCommandString_App(&bind->command);
+            }
             return iTrue;
         }
     }
diff --git a/src/ui/keys.h b/src/ui/keys.h
index 0cd97e2a..1e676c59 100644
--- a/src/ui/keys.h
+++ b/src/ui/keys.h
@@ -52,6 +52,7 @@ iDeclareType(Binding)
 
 struct Impl_Binding {
     int id;
+    int flags;
     int key;
     int mods;
     iString command;
diff --git a/src/ui/util.c b/src/ui/util.c
index 85d3562f..559c5381 100644
--- a/src/ui/util.c
+++ b/src/ui/util.c
@@ -1013,6 +1013,8 @@ iWidget *makePreferences_Widget(void) {
         addChild_Widget(values, iClob(makeToggle_Widget("prefs.hoveroutline")));
         addChild_Widget(headings, iClob(makeHeading_Widget("Smooth scrolling:")));
         addChild_Widget(values, iClob(makeToggle_Widget("prefs.smoothscroll")));
+        addChild_Widget(headings, iClob(makeHeading_Widget("Load image on scroll:")));
+        addChild_Widget(values, iClob(makeToggle_Widget("prefs.imageloadscroll")));
     }
     /* Window. */ {
         appendTwoColumnPage_(tabs, "Window", '2', &headings, &values);