Lagrange [work/v1.11]

Merged changes for v1.2 from dev branch

=> b35539d9181bbef06da4fe7f61e55cad822df756

diff --git a/.gitignore b/.gitignore
index 05eba91e..9ef6983d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,5 +4,4 @@
 build-*
 /.vsbuild
 /.vscode
-
-
+/app
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9489f5a9..e13fc2d5 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -18,7 +18,7 @@
 cmake_minimum_required (VERSION 3.9)
 
 project (Lagrange
-    VERSION 1.1.4
+    VERSION 1.2.0
     DESCRIPTION "A Beautiful Gemini Client"
     LANGUAGES C
 )
@@ -33,24 +33,11 @@ option (ENABLE_RELATIVE_EMBED   "Resources should always be found via relative p
 option (ENABLE_WINDOWPOS_FIX    "Set position after showing window (workaround for SDL bug)" OFF)
 option (ENABLE_IDLE_SLEEP       "While idle, sleep in the main thread instead of waiting for events" ON)
 option (ENABLE_DOWNLOAD_EDIT    "Allow changing the Downloads directory" ON)
+option (ENABLE_CUSTOM_FRAME     "Draw a custom window frame (Windows)" OFF)
 
 include (BuildType.cmake)
 include (res/Embed.cmake)
-if (NOT EXISTS ${CMAKE_SOURCE_DIR}/lib/the_Foundation/CMakeLists.txt)
-    set (INSTALL_THE_FOUNDATION YES)
-    find_package (the_Foundation REQUIRED)
-else ()
-    set (INSTALL_THE_FOUNDATION NO)
-    set (TFDN_STATIC_LIBRARY    ON  CACHE BOOL "")
-    set (TFDN_ENABLE_INSTALL    OFF CACHE BOOL "")
-    set (TFDN_ENABLE_TESTS      OFF CACHE BOOL "")
-    set (TFDN_ENABLE_WEBREQUEST OFF CACHE BOOL "")
-    add_subdirectory (lib/the_Foundation)
-    add_library (the_Foundation::the_Foundation ALIAS the_Foundation)
-endif ()
-find_package (PkgConfig REQUIRED)
-pkg_check_modules (SDL2 REQUIRED sdl2)
-pkg_check_modules (MPG123 IMPORTED_TARGET libmpg123)
+include (Depends.cmake)
 
 # Embedded resources are written to a generated source file.
 message (STATUS "Preparing embedded resources...")
@@ -83,7 +70,7 @@ set (EMBED_RESOURCES
     res/fonts/SourceSansPro-Bold.ttf
     res/fonts/Symbola.ttf
 )
-if (UNIX AND NOT APPLE)
+if ((UNIX AND NOT APPLE) OR MSYS)
     list (APPEND EMBED_RESOURCES res/lagrange-64.png)
 endif ()
 embed_make (${EMBED_RESOURCES})
@@ -122,6 +109,7 @@ set (SOURCES
     src/prefs.c
     src/prefs.h
     src/stb_image.h
+    src/stb_image_resize.h
     src/stb_truetype.h
     src/visited.c
     src/visited.h
@@ -154,14 +142,16 @@ set (SOURCES
     src/ui/metrics.h
     src/ui/paint.c
     src/ui/paint.h
-    src/ui/playerui.c
-    src/ui/playerui.h
+    src/ui/mediaui.c
+    src/ui/mediaui.h
     src/ui/scrollwidget.c
     src/ui/scrollwidget.h
     src/ui/sidebarwidget.c
     src/ui/sidebarwidget.h
     src/ui/text.c
     src/ui/text.h
+    src/ui/touch.c
+    src/ui/touch.h
     src/ui/util.c
     src/ui/util.h
     src/ui/visbuf.c
@@ -184,7 +174,18 @@ set (SOURCES
     ${CMAKE_CURRENT_BINARY_DIR}/embedded.h
 )
 if (IOS)
+    add_definitions (-DiPlatformAppleMobile=1)
+    list (APPEND SOURCES
+        src/ios.m
+        src/ios.h
+        app/Images.xcassets
+        res/LaunchScreen.storyboard
+    )
+    set_source_files_properties(app/Images.xcassets PROPERTIES
+        MACOSX_PACKAGE_LOCATION Resources
+    )
 elseif (APPLE)
+    add_definitions (-DiPlatformAppleDesktop=1)
     list (APPEND SOURCES src/macos.m src/macos.h)
     list (APPEND RESOURCES "res/Lagrange.icns")
 endif ()
@@ -231,6 +232,9 @@ endif ()
 if (ENABLE_DOWNLOAD_EDIT)
     target_compile_definitions (app PUBLIC LAGRANGE_DOWNLOAD_EDIT=1)
 endif ()
+if (ENABLE_CUSTOM_FRAME AND MSYS)
+    target_compile_definitions (app PUBLIC LAGRANGE_CUSTOM_FRAME=1)
+endif ()
 target_link_libraries (app PUBLIC the_Foundation::the_Foundation)
 target_link_libraries (app PUBLIC ${SDL2_LDFLAGS})
 if (APPLE)
@@ -239,13 +243,15 @@ if (APPLE)
     else ()
         target_link_libraries (app PUBLIC "-framework AppKit")
     endif ()
-    if (CMAKE_OSX_DEPLOYMENT_TARGET)
+    if (CMAKE_OSX_DEPLOYMENT_TARGET AND NOT IOS)
         target_compile_options (app PUBLIC -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET})
         target_link_options (app PUBLIC -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET})
     endif ()
+    if (SDL2_LIBRARY_DIRS)
+        set_property (TARGET app PROPERTY BUILD_RPATH ${SDL2_LIBRARY_DIRS})
+    endif ()
     set_target_properties (app PROPERTIES
         OUTPUT_NAME "Lagrange"
-        BUILD_RPATH ${SDL2_LIBRARY_DIRS}
         MACOSX_BUNDLE YES
         MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_LIST_DIR}/res/MacOSXBundleInfo.plist.in"
         MACOSX_BUNDLE_BUNDLE_NAME "Lagrange"
@@ -258,9 +264,21 @@ if (APPLE)
         MACOSX_BUNDLE_COPYRIGHT "© ${COPYRIGHT_YEAR} Jaakko Keränen"
         XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "fi.skyjake.Lagrange"
     )
+    if (IOS)
+        set_target_properties (app PROPERTIES
+            XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2"
+            XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon"
+            MACOSX_BUNDLE_ICON_FILE "AppIcon"
+        )
+    endif ()
+    if (XCODE_DEVELOPMENT_TEAM)
+        set_property (TARGET app PROPERTY
+            XCODE_ATTRIBUTE_DEVELOPMENT_TEAM ${XCODE_DEVELOPMENT_TEAM}
+        )
+    endif ()
 endif ()
 if (MSYS)
-    target_link_libraries (app PUBLIC d2d1 uuid) # querying DPI
+    target_link_libraries (app PUBLIC d2d1 uuid dwmapi) # querying DPI
 endif ()
 if (UNIX)
     target_link_libraries (app PUBLIC m)
diff --git a/Depends-iOS.cmake b/Depends-iOS.cmake
new file mode 100644
index 00000000..013ee09a
--- /dev/null
+++ b/Depends-iOS.cmake
@@ -0,0 +1,21 @@
+message (STATUS "iOS dependency directory: ${IOS_DIR}")
+
+find_package (the_Foundation REQUIRED)
+
+set (SDL2_INCLUDE_DIRS ${IOS_DIR}/include/SDL2)
+set (SDL2_LDFLAGS 
+    ${IOS_DIR}/lib/libSDL2.a
+    "-framework AudioToolbox"
+    "-framework AVFoundation"
+    "-framework CoreAudio"
+    "-framework CoreGraphics"
+    "-framework CoreHaptics"
+    "-framework CoreMotion"
+    "-framework Foundation"
+    "-framework Foundation"
+    "-framework GameController"
+    "-framework Metal"
+    "-framework OpenGLES"
+    "-framework QuartzCore"
+    "-framework UIKit"
+)
diff --git a/Depends.cmake b/Depends.cmake
new file mode 100644
index 00000000..b4dacf7c
--- /dev/null
+++ b/Depends.cmake
@@ -0,0 +1,20 @@
+if (IOS)
+    include (Depends-iOS.cmake)
+    return ()
+endif ()
+
+if (NOT EXISTS ${CMAKE_SOURCE_DIR}/lib/the_Foundation/CMakeLists.txt)
+    set (INSTALL_THE_FOUNDATION YES)
+    find_package (the_Foundation REQUIRED)
+else ()
+    set (INSTALL_THE_FOUNDATION NO)
+    set (TFDN_STATIC_LIBRARY    ON  CACHE BOOL "")
+    set (TFDN_ENABLE_INSTALL    OFF CACHE BOOL "")
+    set (TFDN_ENABLE_TESTS      OFF CACHE BOOL "")
+    set (TFDN_ENABLE_WEBREQUEST OFF CACHE BOOL "")
+    add_subdirectory (lib/the_Foundation)
+    add_library (the_Foundation::the_Foundation ALIAS the_Foundation)
+endif ()
+find_package (PkgConfig REQUIRED)
+pkg_check_modules (SDL2 REQUIRED sdl2)
+pkg_check_modules (MPG123 IMPORTED_TARGET libmpg123)
diff --git a/res/LaunchScreen.storyboard b/res/LaunchScreen.storyboard
new file mode 100644
index 00000000..f9a048ed
--- /dev/null
+++ b/res/LaunchScreen.storyboard
@@ -0,0 +1,7 @@
+
+
+    
+        
+    
+    
+
diff --git a/res/MacOSXBundleInfo.plist.in b/res/MacOSXBundleInfo.plist.in
index 1d769768..186e733b 100644
--- a/res/MacOSXBundleInfo.plist.in
+++ b/res/MacOSXBundleInfo.plist.in
@@ -34,6 +34,21 @@
 	
 	NSRequiresAquaSystemAppearance
 	
+	UISupportedInterfaceOrientations
+	
+		UIInterfaceOrientationPortrait
+		UIInterfaceOrientationLandscapeLeft
+		UIInterfaceOrientationLandscapeRight
+	
+	UISupportedInterfaceOrientations~ipad
+	
+		UIInterfaceOrientationPortrait
+		UIInterfaceOrientationPortraitUpsideDown
+		UIInterfaceOrientationLandscapeLeft
+		UIInterfaceOrientationLandscapeRight
+	
+	UILaunchStoryboardName
+	LaunchScreen
 	CFBundleDocumentTypes
 	
 		
diff --git a/res/about/help.gmi b/res/about/help.gmi
index fcdc8239..15485571 100644
--- a/res/about/help.gmi
+++ b/res/about/help.gmi
@@ -6,6 +6,13 @@
 ```
 # Help
 
+## What is Gemini
+
+Gemini is a simple protocol for serving content over the internet. It specifies a Markdown inspired format allowing basic plain text document markup. Compared to HTTP and HTML, Gemini is vastly simpler and easier to work with.
+
+=> gemini://gemini.circumlunar.space/docs/faq.gmi Project Gemini FAQ
+=> gemini://gemini.circumlunar.space/docs/specification.gmi Protocol and 'text/gemini' specification
+
 ## What is Lagrange
 
 Lagrange is a GUI client for browsing Geminispace. It offers modern conveniences familiar from web browsers, such as smooth scrolling, inline image viewing, multiple tabs, visual themes, Unicode fonts, bookmarks, history, and page outlines.
@@ -24,11 +31,12 @@ Like Gemini, Lagrange has been designed with minimalism in mind. It depends on a
 * Sidebar for page outline, managing bookmarks and identities, and viewing history
 * Multiple tabs
 * Identity management — create and use TLS client certificates
-* Subscribe to Gemini feeds
+* Subscribe to Gemini and Atom feeds
 * Use Gemini pages as a source of bookmarks
 * Light and dark color themes
 * Select and copy text with the mouse
 * Find text on the page
+* Search engine integration
 * Open image links inline on the same page
 * Audio playback: MP3, Ogg Vorbis, WAV
 * Open links via keyboard shortcuts
@@ -41,13 +49,6 @@ Like Gemini, Lagrange has been designed with minimalism in mind. It depends on a
 * Built-in support for Gopher
 * Use proxy servers for HTTP, Gopher, or Gemini content
 
-## What is Gemini
-
-Gemini is a simple protocol for serving content over the internet. It specifies a Markdown inspired format allowing basic plain text document markup. Compared to HTTP and HTML, Gemini is vastly simpler and easier to work with.
-
-=> gemini://gemini.circumlunar.space/docs/faq.gmi Project Gemini FAQ
-=> gemini://gemini.circumlunar.space/docs/specification.gmi Protocol and 'text/gemini' specification
-
 ## Why not just use a web browser
 
 Modern web browsers are complex beasts. In fact, they are so complex that one can create a fully functional virtual machine inside one and run another operating system!
@@ -88,6 +89,8 @@ Search within cached pages is limited to the (small) set of pages that Lagrange
 
 Note that the navigation stack is saved to a file when Lagrange is shut down and restored on the next launch. This means the next time you launch Lagrange, you can still search the contents of past pages. However, navigation stacks are tab-specific, so closing a tab will delete its history as well.
 
+You can also make online search queries via the URL input field. When a search URL is configured on the "Network" tab of Preferences, text entered in the URL field is passed to the search URL as a query parameter. A search query will only occur when Enter is pressed while the [⇒ Search Query] indicator is visible.
+
 ### 1.1.2 Links
 
 The type and destination of a link are indicated by the link's icon and color: ➤ links to the same domain, and 🌐 to a different domain. The colors are:
@@ -127,7 +130,13 @@ You can give it a try now with the link below. Either hold down ${ALT}, or press
 
 Press ${CTRL+}T to open a new tab, and ${CTRL+}W to close the current tab. Right-clicking on buttons in the tab bar shows a context menu for additional tab-related functions.
 
-The set of open tabs is restored when you launch Lagrange.
+The set of open tabs and their full contents are saved when you quit the application and restored when relaunch it.
+
+### 1.2.1 Auto-reloading
+
+A tab can be set to auto-reload at given intervals. The setting is remembered until the tab is closed. This is helpful if you keep a  periodically updated page open for longer periods of time.
+
+The feature can be found in the page context menu: right-click and select "Set Auto-Reload...".
 
 ## 1.3 Sidebars
 
@@ -149,6 +158,8 @@ Bookmarks are listed in alphabetic order in the sidebar. There is no support for
 
 In addition to a title, bookmarks can have tags. Some tags have a special meaning, but you are free to enter whatever you want in the tags field. In quick search results, tags are given extra weight so they appear higher in results.
 
+By default bookmarks are assigned random icons. You can enter a custom icon for a bookmark in the bookmark edit dialog to make it easier to recognize a particular site at a glance. The icon must be a single Unicode character. It will appear in the bookmark list, tab titles, feed entry list, quick search results, and page top banners.
+
 ### 1.4.1 Exporting and importing
 
 => about:bookmarks  The special page "about:bookmarks" is used for exporting bookmarks out of Lagrange.
@@ -179,13 +190,15 @@ Note that remote bookmarks are read-only: they cannot be edited or tagged. This
 * "headings" can be used together with "subscribed" to subscribe to new headings instead of Gemini feed links.
 * The "remotesource" tag marks the bookmark as a source of remote bookmarks. All links on the source pages are shown as remote bookmarks in the Bookmarks list.
 * The "remote" tag is used for remote bookmarks. These bookmarks cannot be edited or tagged, but you can make a local duplicate to turn it into a regular bookmark.
+* The "usericon" tag prevents a random icon to be selected for the bookmark. This tag is automatically applied when an icon character is entered in the bookmark editor.
 
 ## 1.5 Subscribing to feeds
 
-You may be familiar with RSS and Atom XML feeds from the web. Lagrange does _not_ support RSS or Atom, only Gemini feeds. A Gemini feed is simply a regular 'text/gemini' page that contains one or more links whose labels are formatted in a particular way.
-
+You may be familiar with XML-based RSS and Atom feeds from the web. The Gemini equivalent of these is Gemini feeds. A Gemini feed is simply a regular 'text/gemini' page that contains one or more links whose labels are formatted in a particular way. This makes it very easy to write pages that clients can subscribe to.
 => gemini://gemini.circumlunar.space/docs/companion/subscription.gmi  See "Subscribing to Gemini pages" for more information.
 
+Lagrange supports Gemini and Atom feed subscriptions. Atom feeds are  automatically translated to the Gemini feed format so they can be viewed and subscribed to like a normal 'text/gemini' page. RSS feeds are not supported.
+
 Subscriptions are managed via bookmarks. When you subscribe to a feed page, a bookmark is created and the special "subscribed" tag is applied on it. In the Bookmarks list, this is indicated by a ★ icon. There is no other difference between normal bookmarks and feed subscriptions — you may tag any bookmark as "subscribed" and Lagrange will look through it for feed-style links. The bookmark title is used as the feed title. This defaults to the top heading of the feed index page, but you can edit it to suit your needs.
 
 Feeds are refreshed periodically while Lagrange is running, and also immediately after launching if it has been a while since the previous refresh. You may also manually refresh all feeds via the menus or by pressing ${SHIFT+}${CTRL+}R.
@@ -259,6 +272,8 @@ There are four different color schemes for the UI: two for dark mode, and two fo
 
 On macOS, Lagrange will automatically switch between dark and light modes if the "Use system theme" setting is enabled. On other platforms you'll need to switch manually.
 
+On Windows, an option is provided to use a custom window frame. This provides a more consistent visual style for the application. However, the custom frame overrides default behaviors of the window, so if you are a Windows power user expect it to not support all the special interactions. For example, when pressing the Windows+Left/Right key, the window is resized to take over one half of the screen. Normally, Windows would then prompt to select another window to fill the rest of the screen, but this does not happen with the custom window frame.
+
 Options for wide window sizes:
 
 * Site icon: If there is room on the left side of the page text, the site icon appears there along with the top level heading. This allows one to keep better track of the current reading position in long documents.
@@ -281,12 +296,14 @@ The "Style" tab of the Preferences dialog lets you customize the appearance and
 
 The fonts for headings and other text are selected separately. This way one can achieve a greater number of style variations. Also, headings that are in a different font are more visually distinct and thus easier for one's eyes to scan.
 
-There are two sans-serif and two serif fonts:
+There are six fonts available:
 
 * "Nunito" is a friendly rounded sans-serif font.
 * "Fira Sans" is a bolder, narrower, and more angular sans-serif font.
 * "Literata" is a heavy and serious serif font.
 * "Tinos" is a lighter serif font.
+* "Source Sans Pro" is the Lagrange UI font.
+* "Iosevka" is the monospace font.
 
 Other style options:
 
@@ -387,7 +404,7 @@ Any output that does not follow this format is considered to mean that the hook
 ## 4.2 mimehooks.txt syntax
 
 Like other Lagrange configuration lines, mimehooks.txt has a simple line-oriented syntax. Lagrange must be restarted for changes to the configuration file to take effect.
-
+≈
 Each hook is specified as three lines:
 * A human-readable label (for reporting to the user)
 * MIME type/parameter regular expression
diff --git a/res/about/version.gmi b/res/about/version.gmi
index 7a08503b..8bd30dfb 100644
--- a/res/about/version.gmi
+++ b/res/about/version.gmi
@@ -6,6 +6,49 @@
 ```
 # Release notes
 
+## 1.2
+
+New features:
+* Atom feed subscriptions: Atom XML documents are automatically converted to Gemini feed index pages. This is a built-in version of the Atom-to-Gemini example on the Help page.
+* Inline downloads: right-click on any link that is openable inside Lagrange and select "Download Linked File".
+* Editable bookmark icons: use your favorite Unicode character as the symbol to represent a bookmarked site.
+* Searching via URL field: non-URL text entered in the field is passed onto the configured search query URL (Preferences > Network). An indicator is shown if a query will take place.
+* Tab auto-reloading: configure a reloading interval using the page context menu ("Set Auto-Reload..."). Auto-reloading is part of the persistent state of the tab.
+* "Iosevka" and "Source Sans Pro" (the UI font) can be used as heading and body fonts.
+* User preference for aligning all pages to the top of the window.
+* Keybinding (F11) for toggling fullscreen mode. On macOS, the shortcut is ⌃⌘F as before.
+* Keybinding for finding text on page.
+
+UI design:
+* Enhanced navbar: adjusted spacing, URL field has a maximum width, tab titles have less pronounced borders.
+* Improved sidebar appearance: bold subheadings, larger feed icons, adjusted spacing, background color.
+* Font consistency: all UI elements use the same font (i.e., no more monospace input fields).
+* Added setting for UI accent color (teal, orange).
+* General fine-tuning of the color palette.
+* Dialog buttons are aligned to the right edge, leaving room for additional action buttons on the left.
+* Page Information button is embedded in the URL field.
+* Page Information dialog is attached to its button.
+* Site icons use a different color in tab titles for visual distinction.
+* Fade background behind modal dialogs.
+* Responsive page margins.
+* Windows: Added a custom window frame to replace the default Windows one. This looks nicer but does not behave exactly like a native window frame. Added a setting to Preferences for switching back to the default frame.
+
+Other changes:
+* Help is opened on first run instead of the "About Lagrange" page to make it easier to discover important Gemini links like the FAQ.
+* "Go to Root" respects a user name found in the URL. One can still "Go to Parent" to get above the user level.
+* Feed entries are sorted by refresh time if they are published on the same date.
+* Don't show future-dated feed entries in Feeds.
+* Middle-clicking on links: open new tab in background or foreground depending on the Shift key.
+* Shift+Insert can be used for pasting clipboard contents into input fields.
+* Removed a strange violet-on-green color theme pairing.
+
+Bug fixes:
+* Fixed text prompt dialogs closing and accepting the entered text when switching focus away from the app.
+* Scroll position remains fixed while horizontally resizing the window or sidebars.
+* Fixed a crash when opening the audio player menu.
+* Fixed Gopher requests that were using URL (percent) encoded characters.
+* Windows: Fixed a flash of white when the window is first opened.
+
 ## 1.1.4
 * Fixed feed entry highlight/read status issue in the sidebar.
 * Fixed Gopher menu links that contain spaces.
@@ -251,7 +294,6 @@
 
 ## 0.5
 * Added MP3 support in the audio player (using mpg123).
-=> https://mpg123.org/ mpg123: MPEG audio player and decoder library
 * Added volume control in the audio player.
 * Metadata in Vorbis and MP3 audio content (title, artist, etc.) is shown in the audio player menu.
 * Added new serif fonts: EB Garamond and Literata.
@@ -261,6 +303,7 @@
 * Open links in new tab with middle mouse button.
 * Fixed failure to find resources when launching via PATH.
 * Fixed color saturation setting not affecting the default color theme.
+=> https://mpg123.org/ mpg123: MPEG audio player and decoder library
 
 ## 0.4.1
 * Set keyboard focus to URL input field after opening a new tab.
@@ -272,8 +315,7 @@
 * Windows: All binaries are signed.
 
 ## 0.4
-* Added audio playback with support for streaming. Supported audio formats in this release are WAV (PCM, mono/stereo, 8/16/24/32 integer/float) and Ogg Vorbis. Shoutout to Sean Barrett et al. for stb_vorbis:
-=> https://github.com/nothings/stb stb: single-file public domain libraries for C/C++
+* Added audio playback with support for streaming. Supported audio formats in this release are WAV (PCM, mono/stereo, 8/16/24/32 integer/float) and Ogg Vorbis. Shoutout to Sean Barrett et al. for stb_vorbis.
 * Added inline audio player that works like inline images. Clicking on an audio link opens the audio player below the link (works for URLs that have file extension .wav/.ogg).
 * Visual fine-tuning: increased Fira Sans line spacing; list bullets use an accent color; adjusted accent colors in the light mode palette.
 * Sidebar has a maximum width — the document must remain visible.
@@ -281,6 +323,7 @@
 * macOS: Use OpenGL on 10.13 for potentially better compatibility.
 * Fixed a memory leak when closing tabs.
 * Fixed unnecessary continual window redrawing related to the scrollbar hover outline.
+=> https://github.com/nothings/stb stb: single-file public domain libraries for C/C++
 
 ## 0.3
 * Added style customization.
diff --git a/res/fi.skyjake.Lagrange.appdata.xml b/res/fi.skyjake.Lagrange.appdata.xml
index 689a609a..355bf18d 100644
--- a/res/fi.skyjake.Lagrange.appdata.xml
+++ b/res/fi.skyjake.Lagrange.appdata.xml
@@ -45,6 +45,26 @@
     jaakko.keranen@iki.fi
 
     
+        
+            
+                

This is a major feature update that also has a number of user  + interface design changes. +

 +

New features include viewing and subscribing to Atom feeds, + downloading any link as a file, editable bookmark icons, + search engine integration, tab auto-reloading, fullscreen mode, + and new font options for page content. +

 +

UI enhancements include improved navbar and sidebar appearance, + setting for UI accent color, and placement of dialog + buttons. +

 +

The full release notes can be viewed inside the app by opening + the "about:version" page. +

 +
 + https://github.com/skyjake/lagrange/releases/tag/v1.2.0  +
  

Bug fixes:

 diff --git a/src/app.c b/src/app.c index f5833c95..17b51dd4 100644 --- a/src/app.c +++ b/src/app.c @@ -61,9 +61,12 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ #include  #include   -#if defined (iPlatformApple) && !defined (iPlatformIOS) +#if defined (iPlatformAppleDesktop) # include "macos.h" #endif +#if defined (iPlatformAppleMobile) +# include "ios.h" +#endif #if defined (iPlatformMsys) # include "win32.h" #endif @@ -73,10 +76,14 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */  iDeclareType(App)  -#if defined (iPlatformApple) +#if defined (iPlatformAppleDesktop) #define EMB_BIN "../../Resources/resources.lgr" static const char *defaultDataDir_App_ = "~/Library/Application Support/fi.skyjake.Lagrange"; #endif +#if defined (iPlatformAppleMobile) +#define EMB_BIN "../../Resources/resources.lgr" +static const char *defaultDataDir_App_ = "~/Library/Application Support"; +#endif #if defined (iPlatformMsys) #define EMB_BIN "../resources.lgr" static const char *defaultDataDir_App_ = "~/AppData/Roaming/fi.skyjake.Lagrange"; @@ -119,6 +126,7 @@ struct Impl_App { iStringList *launchCommands; iBool isFinishedLaunching; iTime lastDropTime; /* for detecting drops of multiple items */ + int autoReloadTimer; /* Preferences: */ iBool commandEcho; /* --echo */ iBool forceSoftwareRender; /* --sw */ @@ -154,33 +162,45 @@ static iString *serializePrefs_App_(const iApp *d) { iString *str = new_String(); const iSidebarWidget *sidebar = findWidget_App("sidebar"); const iSidebarWidget *sidebar2 = findWidget_App("sidebar2"); +#if defined (LAGRANGE_CUSTOM_FRAME) + appendFormat_String(str, "customframe arg:%d\n", d->prefs.customFrame); +#endif appendFormat_String(str, "window.retain arg:%d\n", d->prefs.retainWindowSize); if (d->prefs.retainWindowSize) { - const iBool isMaximized = (SDL_GetWindowFlags(d->window->win) & SDL_WINDOW_MAXIMIZED) != 0; int w, h, x, y; - x = d->window->lastRect.pos.x; - y = d->window->lastRect.pos.y; - w = d->window->lastRect.size.x; - h = d->window->lastRect.size.y; + x = d->window->place.normalRect.pos.x; + y = d->window->place.normalRect.pos.y; + w = d->window->place.normalRect.size.x; + h = d->window->place.normalRect.size.y; appendFormat_String(str, "window.setrect width:%d height:%d coord:%d %d\n", w, h, x, y); appendFormat_String(str, "sidebar.width arg:%d\n", width_SidebarWidget(sidebar)); appendFormat_String(str, "sidebar2.width arg:%d\n", width_SidebarWidget(sidebar2)); /* On macOS, maximization should be applied at creation time or the window will take a moment to animate to its maximized size. */ -#if !defined (iPlatformApple) - if (isMaximized) { +#if defined (LAGRANGE_CUSTOM_FRAME) + if (snap_Window(d->window)) { + if (~SDL_GetWindowFlags(d->window->win) & SDL_WINDOW_MINIMIZED) { + /* Save the actual visible window position, too, because snapped windows may + still be resized/moved without affecting normalRect. */ + SDL_GetWindowPosition(d->window->win, &x, &y); + SDL_GetWindowSize(d->window->win, &w, &h); + appendFormat_String( + str, "~window.setrect snap:%d width:%d height:%d coord:%d %d\n", + snap_Window(d->window), w, h, x, y); + } + } +#elif !defined (iPlatformApple) + if (snap_Window(d->window) == maximized_WindowSnap) { appendFormat_String(str, "~window.maximize\n"); } -#else - iUnused(isMaximized); #endif } /* Sidebars. */ { - if (isVisible_Widget(sidebar)) { + if (isVisible_Widget(sidebar) && deviceType_App() != phone_AppDeviceType) { appendCStr_String(str, "sidebar.toggle\n"); } appendFormat_String(str, "sidebar.mode arg:%d\n", mode_SidebarWidget(sidebar)); - if (isVisible_Widget(sidebar2)) { + if (isVisible_Widget(sidebar2) && deviceType_App() != phone_AppDeviceType) { appendCStr_String(str, "sidebar2.toggle\n"); } appendFormat_String(str, "sidebar2.mode arg:%d\n", mode_SidebarWidget(sidebar2)); @@ -199,9 +219,11 @@ static iString *serializePrefs_App_(const iApp *d) { 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); + appendFormat_String(str, "prefs.centershort.changed arg:%d\n", d->prefs.centerShortDocs); appendFormat_String(str, "quoteicon.set arg:%d\n", d->prefs.quoteIcon ? 1 : 0); appendFormat_String(str, "prefs.hoverlink.changed arg:%d\n", d->prefs.hoverLink); appendFormat_String(str, "theme.set arg:%d auto:1\n", d->prefs.theme); + appendFormat_String(str, "accent.set arg:%d\n", d->prefs.accent); appendFormat_String(str, "ostheme arg:%d\n", d->prefs.useSystemTheme); appendFormat_String(str, "doctheme.dark.set arg:%d\n", d->prefs.docThemeDark); appendFormat_String(str, "doctheme.light.set arg:%d\n", d->prefs.docThemeLight); @@ -210,6 +232,7 @@ static iString *serializePrefs_App_(const iApp *d) { appendFormat_String(str, "proxy.gopher address:%s\n", cstr_String(&d->prefs.gopherProxy)); appendFormat_String(str, "proxy.http address:%s\n", cstr_String(&d->prefs.httpProxy)); appendFormat_String(str, "downloads path:%s\n", cstr_String(&d->prefs.downloadDir)); + appendFormat_String(str, "searchurl address:%s\n", cstr_String(&d->prefs.searchUrl)); return str; }  @@ -270,7 +293,10 @@ static void loadPrefs_App_(iApp *d) { if (equal_Command(cmd, "uiscale")) { setUiScale_Window(get_Window(), argf_Command(cmd)); } - else if (equal_Command(cmd, "window.setrect")) { + else if (equal_Command(cmd, "customframe")) { + d->prefs.customFrame = arg_Command(cmd); + } + else if (equal_Command(cmd, "window.setrect") && !argLabel_Command(cmd, "snap")) { const iInt2 pos = coord_Command(cmd); d->initialWindowRect = init_Rect( pos.x, pos.y, argLabel_Command(cmd, "width"), argLabel_Command(cmd, "height")); @@ -285,6 +311,9 @@ static void loadPrefs_App_(iApp *d) { else { /* default preference values */ } +#if !defined (LAGRANGE_CUSTOM_FRAME) + d->prefs.customFrame = iFalse; +#endif iRelease(f); }  @@ -370,6 +399,9 @@ static void saveState_App_(const iApp *d) { serializeState_DocumentWidget(i.object, stream_File(f)); } } + else { + fprintf(stderr, "[App] failed to save state: %s\n", strerror(errno)); + } iRelease(f); }  @@ -384,6 +416,12 @@ static uint32_t checkAsleep_App_(uint32_t interval, void *param) { } #endif  +static uint32_t postAutoReloadCommand_App_(uint32_t interval, void *param) { + iUnused(param); + postCommand_App("document.autoreload"); + return interval; +} + static void init_App_(iApp *d, int argc, char **argv) { init_CommandLine(&d->args, argc, argv); /* Where was the app started from? We ask SDL first because the command line alone is @@ -438,8 +476,11 @@ static void init_App_(iApp *d, int argc, char **argv) { d->lastEventTime = 0; d->sleepTimer = SDL_AddTimer(1000, checkAsleep_App_, d); #endif -#if defined (iPlatformApple) +#if defined (iPlatformAppleDesktop) setupApplication_MacOS(); +#if defined (iPlatformAppleMobile) +#endif + setupApplication_iOS(); #endif init_Keys(); loadPrefs_App_(d); @@ -476,9 +517,11 @@ static void init_App_(iApp *d, int argc, char **argv) { /* Widget state init. */ processEvents_App(postedEventsOnly_AppEventMode); if (!loadState_App_(d)) { - postCommand_App("navigate.home"); + postCommand_App("open url:about:help"); } postCommand_App("window.unfreeze"); + d->autoReloadTimer = SDL_AddTimer(60 * 1000, postAutoReloadCommand_App_, NULL); + postCommand_App("document.autoreload"); d->isFinishedLaunching = iTrue; /* Run any commands that were pending completion of launch. */ { iForEach(StringList, i, d->launchCommands) { @@ -539,6 +582,61 @@ const iString *downloadDir_App(void) { return collect_String(cleaned_Path(&app_.prefs.downloadDir)); }  +const iString *downloadPathForUrl_App(const iString *url, const iString *mime) { + /* Figure out a file name from the URL. */ + iUrl parts; + init_Url(&parts, url); + while (startsWith_Rangecc(parts.path, "/")) { + parts.path.start++; + } + while (endsWith_Rangecc(parts.path, "/")) { + parts.path.end--; + } + iString *name = collectNewCStr_String("pagecontent"); + if (isEmpty_Range(&parts.path)) { + if (!isEmpty_Range(&parts.host)) { + setRange_String(name, parts.host); + replace_Block(&name->chars, '.', '_'); + } + } + else { + iRangecc fn = { parts.path.start + lastIndexOfCStr_Rangecc(parts.path, "/") + 1, + parts.path.end }; + if (!isEmpty_Range(&fn)) { + setRange_String(name, fn); + } + } + if (startsWith_String(name, "~")) { + /* This would be interpreted as a reference to a home directory. */ + remove_Block(&name->chars, 0, 1); + } + iString *savePath = concat_Path(downloadDir_App(), name); + if (lastIndexOfCStr_String(savePath, ".") == iInvalidPos) { + /* No extension specified in URL. */ + if (startsWith_String(mime, "text/gemini")) { + appendCStr_String(savePath, ".gmi"); + } + else if (startsWith_String(mime, "text/")) { + appendCStr_String(savePath, ".txt"); + } + else if (startsWith_String(mime, "image/")) { + appendCStr_String(savePath, cstr_String(mime) + 6); + } + } + if (fileExists_FileInfo(savePath)) { + /* Make it unique. */ + iDate now; + initCurrent_Date(&now); + size_t insPos = lastIndexOfCStr_String(savePath, "."); + if (insPos == iInvalidPos) { + insPos = size_String(savePath); + } + const iString *date = collect_String(format_Date(&now, "_%Y-%m-%d_%H%M%S")); + insertData_Block(&savePath->chars, insPos, cstr_String(date), size_String(date)); + } + return collect_String(savePath); +} + const iString *debugInfo_App(void) { extern char **environ; /* The environment variables. */ iApp *d = &app_; @@ -570,6 +668,39 @@ const iString *debugInfo_App(void) { return msg; }  +static void clearCache_App_(void) { + iForEach(ObjectList, i, iClob(listDocuments_App())) { + clearCache_History(history_DocumentWidget(i.object)); + } +} + +void trimCache_App(void) { + iApp *d = &app_; + size_t cacheSize = 0; + const size_t limit = d->prefs.maxCacheSize * 1000000; + iObjectList *docs = listDocuments_App(); + iForEach(ObjectList, i, docs) { + cacheSize += cacheSize_History(history_DocumentWidget(i.object)); + } + init_ObjectListIterator(&i, docs); + iBool wasPruned = iFalse; + while (cacheSize > limit) { + iDocumentWidget *doc = i.object; + const size_t pruned = pruneLeastImportant_History(history_DocumentWidget(doc)); + if (pruned) { + cacheSize -= pruned; + wasPruned = iTrue; + } + next_ObjectListIterator(&i); + if (!i.value) { + if (!wasPruned) break; + wasPruned = iFalse; + init_ObjectListIterator(&i, docs); + } + } + iRelease(docs); +} + iLocalDef iBool isWaitingAllowed_App_(iApp *d) { #if defined (LAGRANGE_IDLE_SLEEP) if (d->isIdling) { @@ -587,10 +718,31 @@ void processEvents_App(enum iAppEventMode eventMode) { SDL_WaitEvent(&ev)) || ((!isWaitingAllowed_App_(d) || eventMode == postedEventsOnly_AppEventMode) && SDL_PollEvent(&ev))) { +#if defined (iPlatformAppleMobile) + if (processEvent_iOS(&ev)) { + continue; + } +#endif switch (ev.type) { case SDL_QUIT: d->isRunning = iFalse; + if (findWidget_App("prefs")) { + /* Make sure changed preferences get saved. */ + postCommand_App("prefs.dismiss"); + processEvents_App(postedEventsOnly_AppEventMode); + } goto backToMainLoop; + case SDL_APP_LOWMEMORY: + clearCache_App_(); + break; + case SDL_APP_WILLENTERFOREGROUND: + postRefresh_App(); + break; + case SDL_APP_TERMINATING: + case SDL_APP_WILLENTERBACKGROUND: + savePrefs_App_(d); + saveState_App_(d); + break; case SDL_DROPFILE: { iBool wasUsed = processEvent_Window(d->window, &ev); if (!wasUsed) { @@ -637,7 +789,7 @@ void processEvents_App(enum iAppEventMode eventMode) { wasUsed = processEvent_Keys(&ev); } if (ev.type == SDL_USEREVENT && ev.user.code == command_UserEventCode) { -#if defined (iPlatformApple) && !defined (iPlatformIOS) +#if defined (iPlatformAppleDesktop) handleCommand_MacOS(command_UserEvent(&ev)); #endif if (isCommand_UserEvent(&ev, "metrics.changed")) { @@ -857,6 +1009,20 @@ iMimeHooks *mimeHooks_App(void) { return app_.mimehooks; }  +iBool isLandscape_App(void) { + const iApp *d = &app_; + const iInt2 size = rootSize_Window(d->window); + return size.x > size.y; +} + +enum iAppDeviceType deviceType_App(void) { +#if defined (iPlatformAppleMobile) + return isPhone_iOS() ? phone_AppDeviceType : tablet_AppDeviceType; +#else + return desktop_AppDeviceType; +#endif +} + iGmCerts *certs_App(void) { return app_.certs; } @@ -875,20 +1041,34 @@ static void updatePrefsThemeButtons_(iWidget *d) { selected_WidgetFlag, colorTheme_App() == i); } + for (size_t i = 0; i < max_ColorAccent; i++) { + setFlags_Widget(findChild_Widget(d, format_CStr("prefs.accent.%u", i)), + selected_WidgetFlag, + prefs_App()->accent == i); + } }  -static void updateColorThemeButton_(iLabelWidget *button, int theme) { - const char *mode = strstr(cstr_String(id_Widget(as_Widget(button))), ".dark") ? "dark" : "light"; - const char *command = format_CStr("doctheme.%s.set arg:%d", mode, theme); - iForEach(ObjectList, i, children_Widget(findChild_Widget(as_Widget(button), "menu"))) { +static void updateDropdownSelection_(iLabelWidget *dropButton, const char *selectedCommand) { + iForEach(ObjectList, i, children_Widget(findChild_Widget(as_Widget(dropButton), "menu"))) { iLabelWidget *item = i.object; - if (!cmp_String(command_LabelWidget(item), command)) { - updateText_LabelWidget(button, text_LabelWidget(item)); - break; + const iBool isSelected = endsWith_String(command_LabelWidget(item), selectedCommand); + setFlags_Widget(as_Widget(item), selected_WidgetFlag, isSelected); + if (isSelected) { + updateText_LabelWidget(dropButton, text_LabelWidget(item)); } } }  +static void updateColorThemeButton_(iLabelWidget *button, int theme) { +// const char *mode = strstr(cstr_String(id_Widget(as_Widget(button))), ".dark") +// ? "dark" : "light"; + updateDropdownSelection_(button, format_CStr(".set arg:%d", theme)); +} + +static void updateFontButton_(iLabelWidget *button, int font) { + updateDropdownSelection_(button, format_CStr(".set arg:%d", font)); +} + static iBool handlePrefsCommands_(iWidget *d, const char *cmd) { if (equal_Command(cmd, "prefs.dismiss") || equal_Command(cmd, "preferences")) { setUiScale_Window(get_Window(), @@ -897,6 +1077,8 @@ static iBool handlePrefsCommands_(iWidget *d, const char *cmd) { postCommandf_App("downloads path:%s", cstr_String(text_InputWidget(findChild_Widget(d, "prefs.downloads")))); #endif + postCommandf_App("customframe arg:%d", + isSelected_Widget(findChild_Widget(d, "prefs.customframe"))); postCommandf_App("window.retain arg:%d", isSelected_Widget(findChild_Widget(d, "prefs.retainwindow"))); postCommandf_App("smoothscroll arg:%d", @@ -907,6 +1089,8 @@ static iBool handlePrefsCommands_(iWidget *d, const char *cmd) { isSelected_Widget(findChild_Widget(d, "prefs.ostheme"))); postCommandf_App("decodeurls arg:%d", isSelected_Widget(findChild_Widget(d, "prefs.decodeurls"))); + postCommandf_App("searchurl address:%s", + cstr_String(text_InputWidget(findChild_Widget(d, "prefs.searchurl")))); postCommandf_App("cachesize.set arg:%d", toInt_String(text_InputWidget(findChild_Widget(d, "prefs.cachesize")))); postCommandf_App("proxy.gemini address:%s", @@ -919,6 +1103,7 @@ static iBool handlePrefsCommands_(iWidget *d, const char *cmd) { postCommandf_App("prefs.dialogtab arg:%u", tabPageIndex_Widget(tabs, currentTabPage_Widget(tabs))); destroy_Widget(d); + postCommand_App("prefs.changed"); return iTrue; } else if (equal_Command(cmd, "quoteicon.set")) { @@ -935,6 +1120,14 @@ static iBool handlePrefsCommands_(iWidget *d, const char *cmd) { updateColorThemeButton_(findChild_Widget(d, "prefs.doctheme.light"), arg_Command(cmd)); return iFalse; } + else if (equal_Command(cmd, "font.set")) { + updateFontButton_(findChild_Widget(d, "prefs.font"), arg_Command(cmd)); + else if (equal_Command(cmd, "headingfont.set")) { + return iFalse; + } + updateFontButton_(findChild_Widget(d, "prefs.headingfont"), arg_Command(cmd)); + return iFalse; + } else if (equal_Command(cmd, "prefs.ostheme.changed")) { postCommandf_App("ostheme arg:%d", arg_Command(cmd)); } @@ -992,33 +1185,6 @@ iDocumentWidget *newTab_App(const iDocumentWidget *duplicateOf, iBool switchToNe return doc; }  -void trimCache_App(void) { - iApp *d = &app_; - size_t cacheSize = 0; - const size_t limit = d->prefs.maxCacheSize * 1000000; - iObjectList *docs = listDocuments_App(); - iForEach(ObjectList, i, docs) { - cacheSize += cacheSize_History(history_DocumentWidget(i.object)); - } - init_ObjectListIterator(&i, docs); - iBool wasPruned = iFalse; - while (cacheSize > limit) { - iDocumentWidget *doc = i.object; - const size_t pruned = pruneLeastImportant_History(history_DocumentWidget(doc)); - if (pruned) { - cacheSize -= pruned; - wasPruned = iTrue; - } - next_ObjectListIterator(&i); - if (!i.value) { - if (!wasPruned) break; - wasPruned = iFalse; - init_ObjectListIterator(&i, docs); - } - } - iRelease(docs); -} - static iBool handleIdentityCreationCommands_(iWidget *dlg, const char *cmd) { iApp *d = &app_; if (equal_Command(cmd, "ident.temp.changed")) { @@ -1092,6 +1258,15 @@ iBool willUseProxy_App(const iRangecc scheme) { return schemeProxy_App(scheme) != NULL; }  +const iString *searchQueryUrl_App(const iString *queryStringUnescaped) { + iApp *d = &app_; + if (isEmpty_String(&d->prefs.searchUrl)) { + return collectNew_String(); + } + const iString *escaped = urlEncode_String(queryStringUnescaped); + return collectNewFormat_String("%s?%s", cstr_String(&d->prefs.searchUrl), cstr_String(escaped)); +} + iBool handleCommand_App(const char *cmd) { iApp *d = &app_; if (equal_Command(cmd, "config.error")) { @@ -1100,6 +1275,10 @@ iBool handleCommand_App(const char *cmd) { suffixPtr_Command(cmd, "where"))); return iTrue; } + else if (equal_Command(cmd, "prefs.changed")) { + savePrefs_App_(d); + return iTrue; + } else if (equal_Command(cmd, "prefs.dialogtab")) { d->prefs.dialogTab = arg_Command(cmd); return iTrue; @@ -1108,8 +1287,24 @@ iBool handleCommand_App(const char *cmd) { d->prefs.retainWindowSize = arg_Command(cmd); return iTrue; } + else if (equal_Command(cmd, "customframe")) { + d->prefs.customFrame = arg_Command(cmd); + return iTrue; + } else if (equal_Command(cmd, "window.maximize")) { - SDL_MaximizeWindow(d->window->win); + if (!argLabel_Command(cmd, "toggle")) { + setSnap_Window(d->window, maximized_WindowSnap); + } + else { + setSnap_Window(d->window, snap_Window(d->window) == maximized_WindowSnap ? 0 : + maximized_WindowSnap); + } + return iTrue; + } + else if (equal_Command(cmd, "window.fullscreen")) { + const iBool wasFull = snap_Window(d->window) == fullscreen_WindowSnap; + setSnap_Window(d->window, wasFull ? 0 : fullscreen_WindowSnap); + postCommandf_App("window.fullscreen.changed arg:%d", !wasFull); return iTrue; } else if (equal_Command(cmd, "font.set")) { @@ -1170,6 +1365,12 @@ iBool handleCommand_App(const char *cmd) { postCommandf_App("theme.changed auto:%d", isAuto); return iTrue; } + else if (equal_Command(cmd, "accent.set")) { + d->prefs.accent = arg_Command(cmd); + setThemePalette_Color(d->prefs.theme); + postCommandf_App("theme.changed auto:1"); + return iTrue; + } else if (equal_Command(cmd, "ostheme")) { d->prefs.useSystemTheme = arg_Command(cmd); return iTrue; @@ -1219,6 +1420,11 @@ iBool handleCommand_App(const char *cmd) { postRefresh_App(); return iTrue; } + else if (equal_Command(cmd, "prefs.centershort.changed")) { + d->prefs.centerShortDocs = arg_Command(cmd) != 0; + postCommand_App("theme.changed"); + return iTrue; + } else if (equal_Command(cmd, "prefs.hoverlink.changed")) { d->prefs.hoverLink = arg_Command(cmd) != 0; postRefresh_App(); @@ -1241,6 +1447,17 @@ iBool handleCommand_App(const char *cmd) { } return iTrue; } + else if (equal_Command(cmd, "searchurl")) { + iString *url = &d->prefs.searchUrl; + setCStr_String(url, suffixPtr_Command(cmd, "address")); + if (startsWith_String(url, "//")) { + prependCStr_String(url, "gemini:"); + } + if (!isEmpty_String(url) && !startsWithCase_String(url, "gemini://")) { + prependCStr_String(url, "gemini://"); + } + return iTrue; + } else if (equal_Command(cmd, "proxy.gemini")) { setCStr_String(&d->prefs.geminiProxy, suffixPtr_Command(cmd, "address")); return iTrue; @@ -1332,7 +1549,13 @@ iBool handleCommand_App(const char *cmd) { return iTrue; } else if (equal_Command(cmd, "tabs.close")) { - iWidget * tabs = findWidget_App("doctabs"); + iWidget *tabs = findWidget_App("doctabs"); +#if defined (iPlatformAppleMobile) + /* Can't close the last on mobile. */ + if (tabCount_Widget(tabs) == 1) { + return iTrue; + } +#endif const iRangecc tabId = range_Command(cmd, "id"); iWidget * doc = !isEmpty_Range(&tabId) ? findWidget_App(cstr_Rangecc(tabId)) : document_App(); @@ -1385,6 +1608,7 @@ iBool handleCommand_App(const char *cmd) { 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.customframe"), d->prefs.customFrame); setToggle_Widget(findChild_Widget(dlg, "prefs.retainwindow"), d->prefs.retainWindowSize); setText_InputWidget(findChild_Widget(dlg, "prefs.uiscale"), collectNewFormat_String("%g", uiScale_Window(d->window))); @@ -1411,8 +1635,11 @@ iBool handleCommand_App(const char *cmd) { iTrue); setToggle_Widget(findChild_Widget(dlg, "prefs.biglede"), d->prefs.bigFirstParagraph); setToggle_Widget(findChild_Widget(dlg, "prefs.sideicon"), d->prefs.sideIcon); + setToggle_Widget(findChild_Widget(dlg, "prefs.centershort"), d->prefs.centerShortDocs); updateColorThemeButton_(findChild_Widget(dlg, "prefs.doctheme.dark"), d->prefs.docThemeDark); updateColorThemeButton_(findChild_Widget(dlg, "prefs.doctheme.light"), d->prefs.docThemeLight); + updateFontButton_(findChild_Widget(dlg, "prefs.font"), d->prefs.font); + updateFontButton_(findChild_Widget(dlg, "prefs.headingfont"), d->prefs.headingFont); setFlags_Widget( findChild_Widget( dlg, format_CStr("prefs.saturation.%d", (int) (d->prefs.saturation * 3.99f))), @@ -1421,6 +1648,7 @@ iBool handleCommand_App(const char *cmd) { setText_InputWidget(findChild_Widget(dlg, "prefs.cachesize"), collectNewFormat_String("%d", d->prefs.maxCacheSize)); setToggle_Widget(findChild_Widget(dlg, "prefs.decodeurls"), d->prefs.decodeUserVisibleURLs); + setText_InputWidget(findChild_Widget(dlg, "prefs.searchurl"), &d->prefs.searchUrl); setText_InputWidget(findChild_Widget(dlg, "prefs.proxy.gemini"), &d->prefs.geminiProxy); setText_InputWidget(findChild_Widget(dlg, "prefs.proxy.gopher"), &d->prefs.gopherProxy); setText_InputWidget(findChild_Widget(dlg, "prefs.proxy.http"), &d->prefs.httpProxy); @@ -1568,9 +1796,10 @@ void openInDefaultBrowser_App(const iString *url) { return; } #endif +#if !defined (iPlatformAppleMobile) iProcess *proc = new_Process(); setArguments_Process(proc, -#if defined (iPlatformApple) +#if defined (iPlatformAppleDesktop) iClob(newStringsCStr_StringList("/usr/bin/env", "open", cstr_String(url), NULL)) #elif defined (iPlatformLinux) || defined (iPlatformOther) iClob(newStringsCStr_StringList("/usr/bin/env", "xdg-open", cstr_String(url), NULL)) @@ -1584,10 +1813,11 @@ void openInDefaultBrowser_App(const iString *url) { ); start_Process(proc); iRelease(proc); +#endif }  void revealPath_App(const iString *path) { -#if defined (iPlatformApple) +#if defined (iPlatformAppleDesktop) const char *scriptPath = concatPath_CStr(dataDir_App_(), "revealfile.scpt"); iFile *f = newCStr_File(scriptPath); if (open_File(f, writeOnly_FileMode | text_FileMode)) { diff --git a/src/app.h b/src/app.h index efaf0a3e..9a68c362 100644 --- a/src/app.h +++ b/src/app.h @@ -38,6 +38,12 @@ iDeclareType(MimeHooks) iDeclareType(Visited) iDeclareType(Window)  +enum iAppDeviceType { + desktop_AppDeviceType, + tablet_AppDeviceType, + phone_AppDeviceType, +}; + enum iAppEventMode { waitForNewEvents_AppEventMode, postedEventsOnly_AppEventMode, @@ -61,6 +67,9 @@ void refresh_App (void); iBool isRefreshPending_App (void); uint32_t elapsedSinceLastTicker_App (void); /* milliseconds */  +iBool isLandscape_App (void); +iLocalDef iBool isPortrait_App (void) { return !isLandscape_App(); } +enum iAppDeviceType deviceType_App (void); iGmCerts * certs_App (void); iVisited * visited_App (void); iBookmarks * bookmarks_App (void); @@ -75,6 +84,8 @@ iBool forceSoftwareRender_App(void); enum iColorTheme colorTheme_App (void); const iString * schemeProxy_App (iRangecc scheme); iBool willUseProxy_App (const iRangecc scheme); +const iString * searchQueryUrl_App (const iString *queryStringUnescaped); +const iString * downloadPathForUrl_App(const iString *url, const iString *mime);  typedef void (*iTickerFunc)(iAny *);  diff --git a/src/audio/player.c b/src/audio/player.c index 1c8538b4..d2ec9870 100644 --- a/src/audio/player.c +++ b/src/audio/player.c @@ -566,6 +566,9 @@ static iContentSpec contentSpec_Player_(const iPlayer *d) { stb_vorbis *vrb = stb_vorbis_open_pushdata( constData_Block(&d->data->data), size_Block(&d->data->data), &consumed, &error, NULL); if (!vrb) { + if (error != VORBIS_need_more_data) { + content.type = none_DecoderType; + } return content; } const stb_vorbis_info info = stb_vorbis_get_info(vrb); @@ -793,8 +796,10 @@ iString *metadataLabel_Player(const iPlayer *d) { } unlock_Mutex(&d->decoder->tagMutex); } - appendFormat_String(meta, "%d-bit %s %d Hz", SDL_AUDIO_BITSIZE(d->decoder->inputFormat), - SDL_AUDIO_ISFLOAT(d->decoder->inputFormat) ? "float" : "integer", - d->spec.freq); + if (d->decoder) { + appendFormat_String(meta, "%d-bit %s %d Hz", SDL_AUDIO_BITSIZE(d->decoder->inputFormat), + SDL_AUDIO_ISFLOAT(d->decoder->inputFormat) ? "float" : "integer", + d->spec.freq); + } return meta; } diff --git a/src/bookmarks.c b/src/bookmarks.c index 1fc24a67..91280f3c 100644 --- a/src/bookmarks.c +++ b/src/bookmarks.c @@ -65,8 +65,10 @@ void addTag_Bookmark(iBookmark *d, const char *tag) {  void removeTag_Bookmark(iBookmark *d, const char *tag) { const size_t pos = indexOfCStr_String(&d->tags, tag); - remove_Block(&d->tags.chars, pos, strlen(tag)); - trim_String(&d->tags); + if (pos != iInvalidPos) { + remove_Block(&d->tags.chars, pos, strlen(tag)); + trim_String(&d->tags); + } }  iDefineTypeConstruction(Bookmark) @@ -237,7 +239,7 @@ iBool updateBookmarkIcon_Bookmarks(iBookmarks *d, const iString *url, iChar icon const uint32_t id = findUrl_Bookmarks(d, url); if (id) { iBookmark *bm = get_Bookmarks(d, id); - if (!hasTag_Bookmark(bm, "remote")) { + if (!hasTag_Bookmark(bm, "remote") && !hasTag_Bookmark(bm, "usericon")) { if (icon != bm->icon) { bm->icon = icon; changed = iTrue; @@ -248,6 +250,37 @@ iBool updateBookmarkIcon_Bookmarks(iBookmarks *d, const iString *url, iChar icon return changed; }  +iChar siteIcon_Bookmarks(const iBookmarks *d, const iString *url) { + if (isEmpty_String(url)) { + return 0; + } + static iRegExp *tagPattern_; + if (!tagPattern_) { + tagPattern_ = new_RegExp("\\busericon\\b", caseSensitive_RegExpOption); + } + const iRangecc urlRoot = urlRoot_String(url); + size_t matchingSize = iInvalidSize; /* we'll pick the shortest matching */ + iChar icon = 0; + lock_Mutex(d->mtx); + iConstForEach(Hash, i, &d->bookmarks) { + const iBookmark *bm = (const iBookmark *) i.value; + iRegExpMatch m; + init_RegExpMatch(&m); + if (bm->icon && matchString_RegExp(tagPattern_, &bm->tags, &m)) { + const iRangecc bmRoot = urlRoot_String(&bm->url); + if (equalRangeCase_Rangecc(urlRoot, bmRoot)) { + const size_t n = size_String(&bm->url); + if (n < matchingSize) { + matchingSize = n; + icon = bm->icon; + } + } + } + } + unlock_Mutex(d->mtx); + return icon; +} + iBookmark *get_Bookmarks(iBookmarks *d, uint32_t id) { return (iBookmark *) value_Hash(&d->bookmarks, id); } diff --git a/src/bookmarks.h b/src/bookmarks.h index d5182b48..ab9c683b 100644 --- a/src/bookmarks.h +++ b/src/bookmarks.h @@ -62,6 +62,7 @@ iBookmark * get_Bookmarks (iBookmarks *, uint32_t id); void fetchRemote_Bookmarks (iBookmarks *); void requestFinished_Bookmarks (iBookmarks *, iGmRequest *req); iBool updateBookmarkIcon_Bookmarks(iBookmarks *, const iString *url, iChar icon); +iChar siteIcon_Bookmarks (const iBookmarks *, const iString *url);  void save_Bookmarks (const iBookmarks *, const char *dirPath); uint32_t findUrl_Bookmarks (const iBookmarks *, const iString *url); /* O(n) */ diff --git a/src/feeds.c b/src/feeds.c index 3fb05d14..c66b2b84 100644 --- a/src/feeds.c +++ b/src/feeds.c @@ -598,7 +598,10 @@ void removeEntries_Feeds(uint32_t feedBookmarkId) {  static int cmpTimeDescending_FeedEntryPtr_(const void *a, const void *b) { const iFeedEntry * const *e1 = a, * const *e2 = b; - return -cmp_Time(&(*e1)->posted, &(*e2)->posted); + const int cmpPosted = -cmp_Time(&(*e1)->posted, &(*e2)->posted); + if (cmpPosted) return cmpPosted; + /* Posting timestamps may only be accurate to a day, so also sort by discovery time. */ + return -cmp_Time(&(*e1)->discovered, &(*e2)->discovered); }  const iPtrArray *listEntries_Feeds(void) { diff --git a/src/gmdocument.c b/src/gmdocument.c index f73b7dc4..4e76a22a 100644 --- a/src/gmdocument.c +++ b/src/gmdocument.c @@ -27,6 +27,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ #include "ui/metrics.h" #include "ui/window.h" #include "visited.h" +#include "bookmarks.h" #include "app.h"  #include  @@ -254,6 +255,15 @@ static iBool isForcedMonospace_GmDocument_(const iGmDocument *d) { return iFalse; }  +static void linkContentLaidOut_GmDocument_(iGmDocument *d, const iGmMediaInfo *mediaInfo, + uint16_t linkId) { + iGmLink *link = at_PtrArray(&d->links, linkId - 1); + link->flags |= content_GmLinkFlag; + if (mediaInfo && mediaInfo->isPermanent) { + link->flags |= permanent_GmLinkFlag; + } +} + static void doLayout_GmDocument_(iGmDocument *d) { const iBool isMono = isForcedMonospace_GmDocument_(d); /* TODO: Collect these parameters into a GmTheme. */ @@ -281,10 +291,10 @@ static void doLayout_GmDocument_(iGmDocument *d) { 5, 10, 5, 10, 0, 0, 0, 5 }; static const float topMargin[max_GmLineType] = { - 0.0f, 0.333f, 1.0f, 0.5f, 2.0f, 1.5f, 1.0f, 0.5f + 0.0f, 0.333f, 1.0f, 0.5f, 2.0f, 1.5f, 1.0f, 0.25f }; static const float bottomMargin[max_GmLineType] = { - 0.0f, 0.333f, 1.0f, 0.5f, 0.5f, 0.5f, 0.5f, 0.5f + 0.0f, 0.333f, 1.0f, 0.5f, 0.5f, 0.5f, 0.5f, 0.25f }; static const char *arrow = "\u27a4"; static const char *envelope = "\U0001f4e7"; @@ -294,7 +304,6 @@ static void doLayout_GmDocument_(iGmDocument *d) { static const char *quote = "\u201c"; static const char *magnifyingGlass = "\U0001f50d"; static const char *pointingFinger = "\U0001f449"; - const float midRunSkip = 0; /*0.120f;*/ /* extra space between wrapped text/quote lines */ const iPrefs *prefs = prefs_App(); clear_Array(&d->layout); clearLinks_GmDocument_(d); @@ -325,6 +334,7 @@ static void doLayout_GmDocument_(iGmDocument *d) { iGmRun run = { .color = white_ColorId }; enum iGmLineType type; int indent = 0; + int rightMargin = 0; /* Detect the type of the line. */ if (!isPreformat) { type = lineType_GmDocument_(d, line); @@ -431,8 +441,7 @@ static void doLayout_GmDocument_(iGmDocument *d) { if ((type == link_GmLineType && prevType == link_GmLineType) || (type == quote_GmLineType && prevType == quote_GmLineType)) { /* No margin between consecutive links/quote lines. */ - required = - (type == link_GmLineType ? midRunSkip * lineHeight_Text(paragraph_FontId) : 0); + required = 0; } if (isEmpty_Array(&d->layout)) { required = 0; /* top of document */ @@ -522,17 +531,13 @@ static void doLayout_GmDocument_(iGmDocument *d) { if (!prefs->quoteIcon && type == quote_GmLineType) { run.flags |= quoteBorder_GmRunFlag; } + rightMargin = (type == text_GmLineType || type == bullet_GmLineType || + type == quote_GmLineType ? 4 : 0); iAssert(!isEmpty_Range(&runLine)); /* must have something at this point */ while (!isEmpty_Range(&runLine)) { - /* Little bit of breathing space between wrapped lines. */ - if ((type == text_GmLineType || type == quote_GmLineType || - type == bullet_GmLineType) && - runLine.start != line.start) { - pos.y += midRunSkip * lineHeight_Text(run.font); - } run.bounds.pos = addX_I2(pos, indent * gap_Text); const char *contPos; - const int avail = isPreformat ? 0 : (d->size.x - run.bounds.pos.x); + const int avail = isPreformat ? 0 : (d->size.x - run.bounds.pos.x - rightMargin * gap_Text); const iInt2 dims = tryAdvance_Text(run.font, runLine, avail, &contPos); iChangeFlags(run.flags, wide_GmRunFlag, (isPreformat && dims.x > d->size.x)); run.bounds.size.x = iMax(avail, dims.x); /* Extends to the right edge for selection. */ @@ -562,48 +567,40 @@ static void doLayout_GmDocument_(iGmDocument *d) { if (type == link_GmLineType) { const iMediaId imageId = findLinkImage_Media(d->media, run.linkId); const iMediaId audioId = !imageId ? findLinkAudio_Media(d->media, run.linkId) : 0; + const iMediaId downloadId = !imageId && !audioId ? findLinkDownload_Media(d->media, run.linkId) : 0; if (imageId) { - iGmImageInfo img; + iGmMediaInfo img; imageInfo_Media(d->media, imageId, &img); - /* Mark the link as having content. */ { - iGmLink *link = at_PtrArray(&d->links, run.linkId - 1); - link->flags |= content_GmLinkFlag; - if (img.isPermanent) { - link->flags |= permanent_GmLinkFlag; - } - } + const iInt2 imgSize = imageSize_Media(d->media, imageId); + linkContentLaidOut_GmDocument_(d, &img, run.linkId); const int margin = lineHeight_Text(paragraph_FontId) / 2; pos.y += margin; run.bounds.pos = pos; run.bounds.size.x = d->size.x; - const float aspect = (float) img.size.y / (float) img.size.x; + const float aspect = (float) imgSize.y / (float) imgSize.x; run.bounds.size.y = d->size.x * aspect; run.visBounds = run.bounds; - const iInt2 maxSize = mulf_I2(img.size, get_Window()->pixelRatio); + const iInt2 maxSize = mulf_I2(imgSize, get_Window()->pixelRatio); if (width_Rect(run.visBounds) > maxSize.x) { /* Don't scale the image up. */ - run.visBounds.size.y = run.visBounds.size.y * maxSize.x / width_Rect(run.visBounds); + run.visBounds.size.y = + run.visBounds.size.y * maxSize.x / width_Rect(run.visBounds); run.visBounds.size.x = maxSize.x; - run.visBounds.pos.x = run.bounds.size.x / 2 - width_Rect(run.visBounds) / 2; - run.bounds.size.y = run.visBounds.size.y; + run.visBounds.pos.x = run.bounds.size.x / 2 - width_Rect(run.visBounds) / 2; + run.bounds.size.y = run.visBounds.size.y; } - run.text = iNullRange; - run.font = 0; - run.color = 0; - run.imageId = imageId; + run.text = iNullRange; + run.font = 0; + run.color = 0; + run.mediaType = image_GmRunMediaType; + run.mediaId = imageId; pushBack_Array(&d->layout, &run); pos.y += run.bounds.size.y + margin; } else if (audioId) { - iGmAudioInfo info; + iGmMediaInfo info; audioInfo_Media(d->media, audioId, &info); - /* Mark the link as having content. */ { - iGmLink *link = at_PtrArray(&d->links, run.linkId - 1); - link->flags |= content_GmLinkFlag; - if (info.isPermanent) { - link->flags |= permanent_GmLinkFlag; - } - } + linkContentLaidOut_GmDocument_(d, &info, run.linkId); const int margin = lineHeight_Text(paragraph_FontId) / 2; pos.y += margin; run.bounds.pos = pos; @@ -612,7 +609,25 @@ static void doLayout_GmDocument_(iGmDocument *d) { run.visBounds = run.bounds; run.text = iNullRange; run.color = 0; - run.audioId = audioId; + run.mediaType = audio_GmRunMediaType; + run.mediaId = audioId; + pushBack_Array(&d->layout, &run); + pos.y += run.bounds.size.y + margin; + } + else if (downloadId) { + iGmMediaInfo info; + downloadInfo_Media(d->media, downloadId, &info); + linkContentLaidOut_GmDocument_(d, &info, run.linkId); + const int margin = lineHeight_Text(paragraph_FontId) / 2; + pos.y += margin; + run.bounds.pos = pos; + run.bounds.size.x = d->size.x; + run.bounds.size.y = 2 * lineHeight_Text(uiContent_FontId) + 4 * gap_UI; + run.visBounds = run.bounds; + run.text = iNullRange; + run.color = 0; + run.mediaType = download_GmRunMediaType; + run.mediaId = downloadId; pushBack_Array(&d->layout, &run); pos.y += run.bounds.size.y + margin; } @@ -719,6 +734,13 @@ static void setDerivedThemeColors_(enum iGmDocumentTheme theme) { } }  +static void updateIconBasedOnUrl_GmDocument_(iGmDocument *d) { + const iChar userIcon = siteIcon_Bookmarks(bookmarks_App(), &d->url); + if (userIcon) { + d->siteIcon = userIcon; + } +} + void setThemeSeed_GmDocument(iGmDocument *d, const iBlock *seed) { const iPrefs * prefs = prefs_App(); enum iGmDocumentTheme theme = @@ -803,8 +825,8 @@ void setThemeSeed_GmDocument(iGmDocument *d, const iBlock *seed) { set_Color(tmHeading2_ColorId, mix_Color(get_Color(tmBackground_ColorId), get_Color(black_ColorId), 0.67f)); set_Color(tmHeading3_ColorId, mix_Color(get_Color(tmBackground_ColorId), get_Color(black_ColorId), 0.55f)); setHsl_Color(tmBannerBackground_ColorId, addSatLum_HSLColor(base, 0, -0.1f)); - setHsl_Color(tmBannerIcon_ColorId, addSatLum_HSLColor(base, 0, -0.2f)); - setHsl_Color(tmBannerTitle_ColorId, addSatLum_HSLColor(base, 0, -0.2f)); + setHsl_Color(tmBannerIcon_ColorId, addSatLum_HSLColor(base, 0, -0.4f)); + setHsl_Color(tmBannerTitle_ColorId, addSatLum_HSLColor(base, 0, -0.4f)); setHsl_Color(tmLinkIcon_ColorId, addSatLum_HSLColor(get_HSLColor(teal_ColorId), 0, 0)); set_Color(tmLinkIconVisited_ColorId, mix_Color(get_Color(tmBackground_ColorId), get_Color(teal_ColorId), 0.35f)); set_Color(tmLinkDomain_ColorId, get_Color(teal_ColorId)); @@ -914,7 +936,7 @@ void setThemeSeed_GmDocument(iGmDocument *d, const iBlock *seed) { violet_Hue, pink_Hue }; - static const float hues[] = { 5, 25, 40, 56, 80, 120, 160, 180, 208, 231, 270, 324 }; + static const float hues[] = { 5, 25, 40, 56, 80 + 15, 120, 160, 180, 208, 231, 270, 324 }; static const struct { int index[2]; } altHues[iElemCount(hues)] = { @@ -922,7 +944,7 @@ void setThemeSeed_GmDocument(iGmDocument *d, const iBlock *seed) { { 8, 3 }, /* reddish orange */ { 7, 9 }, /* yellowish orange */ { 5, 7 }, /* yellow */ - { 11, 2 }, /* greenish yellow */ + { 6, 2 }, /* greenish yellow */ { 1, 3 }, /* green */ { 2, 4 }, /* bluish green */ { 2, 11 }, /* cyan */ @@ -968,6 +990,8 @@ void setThemeSeed_GmDocument(iGmDocument *d, const iBlock *seed) { setHsl_Color(tmHeading2_ColorId, setLum_HSLColor(altBase, titleLum + 0.70f)); setHsl_Color(tmHeading3_ColorId, setLum_HSLColor(altBase, titleLum + 0.60f));  +// printf("titleLum: %f\n", titleLum); + setHsl_Color(tmParagraph_ColorId, addSatLum_HSLColor(base, 0.1f, 0.6f));  // printf("heading3: %d,%d,%d\n", get_Color(tmHeading3_ColorId).r, get_Color(tmHeading3_ColorId).g, get_Color(tmHeading3_ColorId).b); @@ -976,11 +1000,8 @@ void setThemeSeed_GmDocument(iGmDocument *d, const iBlock *seed) {  if (delta_Color(get_Color(tmHeading3_ColorId), get_Color(tmParagraph_ColorId)) <= 80) { /* Smallest headings may be too close to body text color. */ -// iHSLColor clr = get_HSLColor(tmParagraph_ColorId); -// clr.lum = iMax(0.5f, clr.lum - 0.15f); - //setHsl_Color(tmParagraph_ColorId, clr); - setHsl_Color(tmHeading3_ColorId, - addSatLum_HSLColor(get_HSLColor(tmHeading3_ColorId), 0, 0.15f)); + setHsl_Color(tmHeading2_ColorId, addSatLum_HSLColor(get_HSLColor(tmHeading2_ColorId), 0.5f, -0.12f)); + setHsl_Color(tmHeading3_ColorId, addSatLum_HSLColor(get_HSLColor(tmHeading3_ColorId), 0.5f, -0.2f)); }  setHsl_Color(tmFirstParagraph_ColorId, addSatLum_HSLColor(base, 0.2f, 0.72f)); @@ -1091,6 +1112,7 @@ void setThemeSeed_GmDocument(iGmDocument *d, const iBlock *seed) { if (equal_CStr(cstr_Block(seed), "gemini.circumlunar.space")) { d->siteIcon = 0x264a; /* gemini symbol */ } + updateIconBasedOnUrl_GmDocument_(d); } #if 0 for (int i = tmFirst_ColorId; i < max_ColorId; ++i) { @@ -1192,6 +1214,7 @@ void setUrl_GmDocument(iGmDocument *d, const iString *url) { iUrl parts; init_Url(&parts, url); setRange_String(&d->localHost, parts.host); + updateIconBasedOnUrl_GmDocument_(d); }  void setSource_GmDocument(iGmDocument *d, const iString *source, int width) { diff --git a/src/gmdocument.h b/src/gmdocument.h index e2c7e10c..16127ea3 100644 --- a/src/gmdocument.h +++ b/src/gmdocument.h @@ -85,17 +85,24 @@ enum iGmRunFlags { wide_GmRunFlag = iBit(6), /* horizontally scrollable */ };  +enum iGmRunMediaType { + none_GmRunMediaType, + image_GmRunMediaType, + audio_GmRunMediaType, + download_GmRunMediaType, +}; + struct Impl_GmRun { iRangecc text; uint8_t font; uint8_t color; uint8_t flags; + uint8_t mediaType; iRect bounds; /* used for hit testing, may extend to edges */ iRect visBounds; /* actual visual bounds */ uint16_t preId; /* preformatted block ID (sequential) */ iGmLinkId linkId; /* zero for non-links */ - uint16_t imageId; /* zero if not an image */ - uint16_t audioId; /* zero if not audio */ + uint16_t mediaId; /* zero if not an image */ };  iDeclareType(GmRunRange) diff --git a/src/gmrequest.c b/src/gmrequest.c index 8626403f..0208dc94 100644 --- a/src/gmrequest.c +++ b/src/gmrequest.c @@ -133,6 +133,7 @@ struct Impl_GmRequest { iTlsRequest * req; iGopher gopher; iGmResponse * resp; + iBool isFilterEnabled; iBool isRespLocked; iBool isRespFiltered; iAtomicInt allowUpdate; @@ -208,7 +209,7 @@ static int processIncomingData_GmRequest_(iGmRequest *d, const iBlock *data) { resp->statusCode = code; d->state = receivingBody_GmRequestState; notifyUpdate = iTrue; - if (willTryFilter_MimeHooks(mimeHooks_App(), &resp->meta)) { + if (d->isFilterEnabled && willTryFilter_MimeHooks(mimeHooks_App(), &resp->meta)) { d->isRespFiltered = iTrue; } } @@ -226,7 +227,12 @@ static int processIncomingData_GmRequest_(iGmRequest *d, const iBlock *data) { static void readIncoming_GmRequest_(iGmRequest *d, iTlsRequest *req) { lock_Mutex(d->mtx); iGmResponse *resp = d->resp; - iAssert(d->state != finished_GmRequestState); /* notifications out of order? */ + if (d->state == finished_GmRequestState || d->state == failure_GmRequestState) { + /* The request has already finished or been aborted (e.g., invalid header). */ + delete_Block(readAll_TlsRequest(req)); + unlock_Mutex(d->mtx); + return; + } iBlock * data = readAll_TlsRequest(req); const int ubits = processIncomingData_GmRequest_(d, data); iBool notifyUpdate = (ubits & 1) != 0; @@ -457,8 +463,9 @@ static void beginGopherConnection_GmRequest_(iGmRequest *d, const iString *host, void init_GmRequest(iGmRequest *d, iGmCerts *certs) { d->mtx = new_Mutex(); d->resp = new_GmResponse(); - d->isRespLocked = iFalse; - d->isRespFiltered = iFalse; + d->isFilterEnabled = iTrue; + d->isRespLocked = iFalse; + d->isRespFiltered = iFalse; set_Atomic(&d->allowUpdate, iTrue); init_String(&d->url); init_Gopher(&d->gopher); @@ -492,6 +499,10 @@ void deinit_GmRequest(iGmRequest *d) { delete_Mutex(d->mtx); }  +void enableFilters_GmRequest(iGmRequest *d, iBool enable) { + d->isFilterEnabled = enable; +} + void setUrl_GmRequest(iGmRequest *d, const iString *url) { set_String(&d->url, urlFragmentStripped_String(url)); /* Encode hostname to Punycode here because we want to submit the Punycode domain name @@ -546,7 +557,7 @@ void submit_GmRequest(iGmRequest *d) { remove_Block(&path->chars, 0, 1); } #endif - iFile * f = new_File(path); + iFile *f = new_File(path); if (open_File(f, readOnly_FileMode)) { /* TODO: Check supported file types: images, audio */ /* TODO: Detect text files based on contents? E.g., is the content valid UTF-8. */ diff --git a/src/gmrequest.h b/src/gmrequest.h index bd340cf1..6d4eb2f8 100644 --- a/src/gmrequest.h +++ b/src/gmrequest.h @@ -64,6 +64,7 @@ iDeclareNotifyFunc(GmRequest, Finished) iDeclareAudienceGetter(GmRequest, updated) iDeclareAudienceGetter(GmRequest, finished)  +void enableFilters_GmRequest (iGmRequest *, iBool enable); void setUrl_GmRequest (iGmRequest *, const iString *url); void submit_GmRequest (iGmRequest *); void cancel_GmRequest (iGmRequest *); diff --git a/src/gmutil.c b/src/gmutil.c index 44bbabfd..2b40367d 100644 --- a/src/gmutil.c +++ b/src/gmutil.c @@ -76,6 +76,9 @@ iLocalDef iBool isDef_(iRangecc cc) {  static iRangecc prevPathSeg_(const char *end, const char *start) { iRangecc seg = { end, end }; + if (start == end) { + return seg; + } do { seg.start--; } while (*seg.start != '/' && seg.start != start); @@ -149,6 +152,37 @@ iRangecc urlHost_String(const iString *d) { return url.host; }  +iRangecc urlUser_String(const iString *d) { + static iRegExp *userPats_[2]; + if (!userPats_[0]) { + userPats_[0] = new_RegExp("~([^/?]+)", 0); + userPats_[1] = new_RegExp("/users/([^/?]+)", caseInsensitive_RegExpOption); + } + iRegExpMatch m; + init_RegExpMatch(&m); + iRangecc found = iNullRange; + iForIndices(i, userPats_) { + if (matchString_RegExp(userPats_[i], d, &m)) { + found = capturedRange_RegExpMatch(&m, 1); + } + } + return found; +} + +iRangecc urlRoot_String(const iString *d) { + const char *rootEnd; + const iRangecc user = urlUser_String(d); + if (!isEmpty_Range(&user)) { + rootEnd = user.end; + } + else { + iUrl parts; + init_Url(&parts, d); + rootEnd = parts.path.start; + } + return (iRangecc){ constBegin_String(d), rootEnd }; +} + static iBool isAbsolutePath_(iRangecc path) { return isAbsolute_Path(collect_String(urlDecode_String(collect_String(newRange_String(path))))); } @@ -197,7 +231,7 @@ void urlEncodePath_String(iString *d) { return; } iString *encoded = new_String(); - appendRange_String(encoded , (iRangecc){ constBegin_String(d), url.path.start }); + appendRange_String(encoded, (iRangecc){ constBegin_String(d), url.path.start }); iString *path = newRange_String(url.path); iString *encPath = urlEncodeExclude_String(path, "%/ "); append_String(encoded, encPath); @@ -272,6 +306,21 @@ const iString *absoluteUrl_String(const iString *d, const iString *urlMaybeRelat return absolute; }  +iBool isLikelyUrl_String(const iString *d) { + /* Guess whether a human intends the string to be an URL. This is supposed to be fuzzy; + not completely per-spec: a) begins with a scheme; b) has something that looks like a + hostname */ + iRegExp *pattern = new_RegExp("^([a-z]+:)?//.*|" + "^(//)?([^/?#: ]+)([/?#:].*)$|" + "^(\\w+(\\.\\w+)+|localhost)$", + caseInsensitive_RegExpOption); + iRegExpMatch m; + init_RegExpMatch(&m); + const iBool likelyUrl = matchString_RegExp(pattern, d, &m); + iRelease(pattern); + return likelyUrl; +} + static iBool equalPuny_(const iString *d, iRangecc orig) { if (!endsWith_String(d, "-")) { return iFalse; /* This is a sufficient condition? */ diff --git a/src/gmutil.h b/src/gmutil.h index 1caf2445..b2cee61a 100644 --- a/src/gmutil.h +++ b/src/gmutil.h @@ -103,7 +103,10 @@ void init_Url (iUrl *, const iString *text);  iRangecc urlScheme_String (const iString *); iRangecc urlHost_String (const iString *); +iRangecc urlUser_String (const iString *); +iRangecc urlRoot_String (const iString *); const iString * absoluteUrl_String (const iString *, const iString *urlMaybeRelative); +iBool isLikelyUrl_String (const iString *); void punyEncodeUrlHost_String(iString *); void stripDefaultUrlPort_String(iString *); const iString * urlFragmentStripped_String(const iString *); diff --git a/src/gopher.c b/src/gopher.c index 0a7489ba..fa8495d7 100644 --- a/src/gopher.c +++ b/src/gopher.c @@ -160,11 +160,11 @@ void open_Gopher(iGopher *d, const iString *url) { d->type = '0'; } else if (parts.path.start < parts.path.end) { - d->type = *parts.path.start; - parts.path.start++; + d->type = *parts.path.start; + parts.path.start++; } else { - d->type = '1'; + d->type = '1'; } if (d->type == '7' && isEmpty_Range(&parts.query)) { /* Ask for the query parameters first. */ @@ -204,12 +204,16 @@ void open_Gopher(iGopher *d, const iString *url) { } d->isPre = iFalse; open_Socket(d->socket); - writeData_Socket(d->socket, parts.path.start, size_Range(&parts.path)); + const iString *reqPath = + collect_String(urlDecodeExclude_String(collectNewRange_String(parts.path), "\t")); + writeData_Socket(d->socket, cstr_String(reqPath), size_String(reqPath)); if (!isEmpty_Range(&parts.query)) { iAssert(*parts.query.start == '?'); parts.query.start++; writeData_Socket(d->socket, "\t", 1); - writeData_Socket(d->socket, parts.query.start, size_Range(&parts.query)); + const iString *reqQuery = + collect_String(urlDecode_String(collectNewRange_String(parts.query))); + writeData_Socket(d->socket, cstr_String(reqQuery), size_String(reqQuery)); } writeData_Socket(d->socket, "\r\n", 2); } diff --git a/src/history.c b/src/history.c index 59d515dc..6876d8e3 100644 --- a/src/history.c +++ b/src/history.c @@ -299,6 +299,18 @@ size_t cacheSize_History(const iHistory *d) { return cached; }  +void clearCache_History(iHistory *d) { + lock_Mutex(d->mtx); + iForEach(Array, i, &d->recent) { + iRecentUrl *url = i.value; + if (url->cachedResponse) { + delete_GmResponse(url->cachedResponse); + url->cachedResponse = NULL; + } + } + unlock_Mutex(d->mtx); +} + size_t pruneLeastImportant_History(iHistory *d) { size_t delta = 0; size_t chosen = iInvalidPos; diff --git a/src/history.h b/src/history.h index 7c2684f1..ce3b8e47 100644 --- a/src/history.h +++ b/src/history.h @@ -56,6 +56,7 @@ iBool goForward_History (iHistory *); iRecentUrl *recentUrl_History (iHistory *, size_t pos); iRecentUrl *mostRecentUrl_History (iHistory *); iRecentUrl *findUrl_History (iHistory *, const iString *url); +void clearCache_History (iHistory *); size_t pruneLeastImportant_History (iHistory *);  const iStringArray * searchContents_History (const iHistory *, const iRegExp *pattern); /* chronologically ascending */ diff --git a/src/ios.h b/src/ios.h new file mode 100644 index 00000000..60841aee --- /dev/null +++ b/src/ios.h @@ -0,0 +1,33 @@ +/* Copyright 2021 Jaakko Keränen  + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ + +#pragma once + +#include "ui/util.h" + +iDeclareType(Window) + +void setupApplication_iOS (void); +

(truncated output; full size was 648.53 KB)

Proxy Information
Original URL
gemini://git.skyjake.fi/lagrange/work%2Fv1.11/cdiff/b35539d9181bbef06da4fe7f61e55cad822df756
Status Code
Success (20)
Meta
text/gemini; charset=utf-8
Capsule Response Time
248.436382 milliseconds
Gemini-to-HTML Time
2.423567 milliseconds

This content has been proxied by September (ba2dc).